From c0899783b6f45ebf4cdc66cdb048988e133fd941 Mon Sep 17 00:00:00 2001 From: Entrpi Date: Wed, 28 Oct 2026 01:16:22 +1100 Subject: [PATCH] test(artifacts): cancel ESP decoding after its first decoded chunk The cancellation test requested cancel or close on setImmediate, which can land while the file is still being opened and checked, so it never showed that decoding had started. It also passed against a reader that ignored both requests while decoding. Acknowledge the decoder's first chunk, then cancel or close, join the operation, and assert that no later chunk reaches the decoder and that the reader ends closed. --- src/artifacts/EspImageArtifactReader.test.ts | 50 +++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/src/artifacts/EspImageArtifactReader.test.ts b/src/artifacts/EspImageArtifactReader.test.ts index fe0e6438a0..0b925df143 100644 --- a/src/artifacts/EspImageArtifactReader.test.ts +++ b/src/artifacts/EspImageArtifactReader.test.ts @@ -1,13 +1,13 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { EspImageArtifactReader } from "./EspImageArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; import type { ArtifactEntry } from "./ArtifactReader.js"; import { extractArtifact } from "./extraction/ArtifactExtraction.js"; -import { ESP8266_REFUSAL } from "../domain/firmware/espImage.js"; +import * as espImageDecoding from "../domain/firmware/espImage.js"; import { ESP32C3_APP_SEGMENTS, esp8266Image, @@ -207,7 +207,7 @@ it("refuses an ESP8266 image with its specific reason", async () => { try { await expect(list(reader)).rejects.toMatchObject({ reason: "unavailable", - message: ESP8266_REFUSAL, + message: espImageDecoding.ESP8266_REFUSAL, }); } finally { await reader.close(); @@ -291,22 +291,58 @@ it.each(["cancel", "close"] as const)( const controller = new AbortController(); const reason = { stage: "decoding" }; let completion: Promise | undefined; + // The decoder acknowledges its first chunk, so the request arrives while + // decoding is active rather than while the file is still being admitted. + let decoded = 0; + let acknowledge = (): void => undefined; + const decoding = new Promise((resolve) => { + acknowledge = resolve; + }); + const create = espImageDecoding.createEspImageDecoder; + const observer = vi + .spyOn(espImageDecoding, "createEspImageDecoder") + .mockImplementation((size) => { + const decoder = create(size); + return { + write: (chunk) => { + decoder.write(chunk); + decoded += 1; + acknowledge(); + }, + get satisfied() { + return decoder.satisfied; + }, + finish: () => decoder.finish(), + }; + }); try { const pending = reader .entries(controller.signal) [Symbol.asyncIterator]() .next(); - setImmediate(() => { - if (action === "cancel") controller.abort(reason); - else completion = reader.close(); - }); + await Promise.race([ + decoding, + pending.then(() => { + throw new Error("Preparation settled before decoding began"); + }), + ]); + const requestedAt = decoded; + if (action === "cancel") controller.abort(reason); + else completion = reader.close(); await expect(pending).rejects.toMatchObject( action === "cancel" ? { reason: "cancelled", cause: reason } : { reason: "unavailable" }, ); await completion; + // The request stops decoding; no later chunk reaches the decoder. + expect(decoded).toBe(requestedAt); + await reader.close(); + await expect( + reader.entries()[Symbol.asyncIterator]().next(), + ).rejects.toMatchObject({ reason: "unavailable" }); } finally { + observer.mockRestore(); await reader.close(); } },