diff --git a/packages/cli/tests/repl-execution.test.ts b/packages/cli/tests/repl-execution.test.ts index af072270f..a7535d0d0 100644 --- a/packages/cli/tests/repl-execution.test.ts +++ b/packages/cli/tests/repl-execution.test.ts @@ -577,7 +577,7 @@ describe("REPL execution: reopening one history", () => { } }); - it("X2: a refused reconstruction leaves nothing of itself behind", function* () { + it("X2: an unprojectable history refuses atomically and leaves nothing behind", function* () { const settled = yield* referenceEvents(); const holder = execution([settled[0], settled[0], ...settled.slice(1)]); const asked = yield* countAsked(); @@ -589,17 +589,10 @@ describe("REPL execution: reopening one history", () => { expect(holder.stream.onAppend).toBe(null); expect(asked.calls).toBe(0); const before = (yield* readAll(holder)).length; + expect(before).toBe(settled.length + 1); yield* holder.stream.append(settled[settled.length - 1]); expect((yield* readAll(holder)).length).toBe(before + 1); }); - - it("X2: a history this version cannot project refuses before anything runs", function* () { - const settled = yield* referenceEvents(); - const holder = execution([settled[0], settled[0], ...settled.slice(1)]); - - expect(refusal(yield* openReplSession(options(holder))).message).toContain("a second entry"); - expect((yield* holder.stream.readAll()).length).toBe(settled.length + 1); - }); }); describe("REPL execution: pausing expansion", () => { diff --git a/packages/web/tests/http-client.ts b/packages/web/tests/http-client.ts index a25664d9f..7c0331a85 100644 --- a/packages/web/tests/http-client.ts +++ b/packages/web/tests/http-client.ts @@ -92,6 +92,7 @@ export function useConnection( let version = 0; let waiting: ((value: void) => void) | undefined; const ended = withResolvers(); + let termination: string | undefined; const advance = (): void => { version += 1; @@ -105,11 +106,13 @@ export function useConnection( advance(); }; const onError = (error: Error): void => { - ended.resolve(`error:${error.message}`); + termination ??= `error:${error.message}`; + ended.resolve(termination); advance(); }; const onClose = (): void => { - ended.resolve("close"); + termination ??= "close"; + ended.resolve(termination); advance(); }; @@ -161,6 +164,11 @@ export function useConnection( buffer = parsed.rest; return parsed.response; } + if (termination !== undefined) { + throw new Error( + `incomplete HTTP response after ${termination}; buffered ${buffer.length} characters; prefix ${JSON.stringify(buffer.slice(0, 160))}`, + ); + } yield* untilAfter(seen); } }, diff --git a/packages/web/tests/server-lifecycle.test.ts b/packages/web/tests/server-lifecycle.test.ts index 4292def65..b46308460 100644 --- a/packages/web/tests/server-lifecycle.test.ts +++ b/packages/web/tests/server-lifecycle.test.ts @@ -1,21 +1,22 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, resource, scoped, sleep, spawn, suspend, withResolvers } from "effection"; +import { ensure, resource, scoped, spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; import { once } from "@effectionx/node/events"; import { when } from "@effectionx/converge"; import { Buffer } from "node:buffer"; import { createServer } from "node:http"; import type { ServerResponse } from "node:http"; -import { connect } from "node:net"; +import { connect, createServer as createPeer } from "node:net"; import type { Socket } from "node:net"; -import { nodeResponseChannel } from "../src/response-channel.ts"; +import { nodeResponseChannel, ResponseClosedError } from "../src/response-channel.ts"; import type { ResponseChannel } from "../src/response-channel.ts"; import { useFormServer } from "../src/server.ts"; import type { FormServer } from "../src/server.ts"; import { addressOf, formInput, portRefuses, watchSubmission } from "./server-support.ts"; import { requestText, useConnection } from "./http-client.ts"; +import type { HttpConnection } from "./http-client.ts"; /** A channel whose completion the test releases, so ordering is observable. */ interface HeldChannel { @@ -319,6 +320,34 @@ function useResponseUnderTest(): Operation { } describe("form server: a response channel belongs to its request task", () => { + it("rejects a production response closed before finish", function* () { + const res = yield* useResponseUnderTest(); + const channel = yield* nodeResponseChannel(res); + res.destroy(); + + let failure: unknown; + try { + yield* channel.finished; + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(ResponseClosedError); + expect(failure instanceof Error ? failure.message : failure).toContain( + "closed before the response finished sending", + ); + }); + + it("keeps a production response successful when close follows finish", function* () { + const res = yield* useResponseUnderTest(); + const channel = yield* nodeResponseChannel(res); + channel.head(204, {}); + channel.end(); + yield* channel.finished; + + res.emit("close"); + yield* channel.finished; + }); + /** * A request abandoned before it answered is the case `finish` never comes * for, so the channel cannot be relying on it to let go. The counts are read @@ -328,30 +357,55 @@ describe("form server: a response channel belongs to its request task", () => { */ it("detaches from the response when the task is halted, and a later finish changes nothing", function* () { const res = yield* useResponseUnderTest(); - const counts = (): number[] => [res.listenerCount("finish"), res.listenerCount("close")]; + const counts = () => ({ + finish: res.listenerCount("finish"), + close: res.listenerCount("close"), + }); const before = counts(); + const acquired = withResolvers(); const owner = yield* spawn(function* () { yield* nodeResponseChannel(res); + acquired.resolve(); yield* suspend(); }); - yield* sleep(0); + yield* acquired.operation; // Anchored to what was live, not to a delta from the baseline: how many // handlers a runtime keeps on its own `ServerResponse`, and when it adds // them, is the runtime's business — Bun attaches one to `finish` that the // others do not. What this case owns is the pair the channel added. const live = counts(); - expect(live[0]).toBeGreaterThan(before[0]); - expect(live[1]).toBeGreaterThan(before[1]); + expect({ + event: "finish", + live: live.finish, + baseline: before.finish, + added: live.finish > before.finish, + }).toEqual({ + event: "finish", + live: live.finish, + baseline: before.finish, + added: true, + }); + expect({ + event: "close", + live: live.close, + baseline: before.close, + added: live.close > before.close, + }).toEqual({ + event: "close", + live: live.close, + baseline: before.close, + added: true, + }); yield* owner.halt(); - expect(counts()).toEqual([live[0] - 1, live[1] - 1]); + expect(counts()).toEqual({ finish: live.finish - 1, close: live.close - 1 }); res.emit("finish"); - expect(counts()).toEqual([live[0] - 1, live[1] - 1]); + expect(counts()).toEqual({ finish: live.finish - 1, close: live.close - 1 }); }); }); @@ -448,12 +502,18 @@ function useKeepAlives(): Operation<{ } /** What a raw client's own socket is carrying. */ -function clientCounts(socket: Socket): number[] { - return [ - socket.listenerCount("data"), - socket.listenerCount("error"), - socket.listenerCount("close"), - ]; +interface ClientCounts { + data: number; + error: number; + close: number; +} + +function clientCounts(socket: Socket): ClientCounts { + return { + data: socket.listenerCount("data"), + error: socket.listenerCount("error"), + close: socket.listenerCount("close"), + }; } describe("form server: accepted sockets and raw clients", () => { @@ -468,11 +528,12 @@ describe("form server: accepted sockets and raw clients", () => { it("releases the accepted socket's close handler and the client's own on cancellation", function* () { const accepted: { socket: Socket; before: number }[] = []; let client: Socket | undefined; - let clientBefore: number[] = []; + let clientBefore: ClientCounts | undefined; let acceptedLive = 0; - let clientLive: number[] = []; + let clientLive: ClientCounts | undefined; let received: () => string = () => ""; let callbacks: () => number = () => 0; + const exchanged = withResolvers(); const owner = yield* spawn(function* () { const server = yield* useFormServer(formInput(), { @@ -490,18 +551,17 @@ describe("form server: accepted sockets and raw clients", () => { yield* connection.response(); acceptedLive = accepted[0]?.socket.listenerCount("close") ?? 0; - clientLive = client ? clientCounts(client) : []; + clientLive = client ? clientCounts(client) : undefined; + exchanged.resolve(); yield* suspend(); }); // Synchronized on the exchange having happened, so both owners are live // with everything attached when the halt lands. - yield* when(function* () { - expect(clientLive.length).toBeGreaterThan(0); - }); + yield* exchanged.operation; const first = accepted[0]; - if (!first || !client) { + if (!first || !client || !clientLive || !clientBefore) { throw new Error("no connection was accepted"); } @@ -510,10 +570,31 @@ describe("form server: accepted sockets and raw clients", () => { // runtime's business — Bun on Linux holds one the others do not. What // these cases own is the pair each added, so the release below is measured // against what was live rather than against the baseline. - expect(acceptedLive).toBeGreaterThanOrEqual(first.before + 1); - clientLive.forEach((count, index) => { - expect(count).toBeGreaterThanOrEqual(clientBefore[index] + 1); + expect({ + event: "close", + live: acceptedLive, + baseline: first.before, + attached: acceptedLive >= first.before + 1, + }).toEqual({ + event: "close", + live: acceptedLive, + baseline: first.before, + attached: true, }); + const events: (keyof ClientCounts)[] = ["data", "error", "close"]; + for (const event of events) { + expect({ + event, + live: clientLive[event], + baseline: clientBefore[event], + attached: clientLive[event] >= clientBefore[event] + 1, + }).toEqual({ + event, + live: clientLive[event], + baseline: clientBefore[event], + attached: true, + }); + } // Empty, because the exchange above consumed it — which is what makes the // replayed chunk below visible if anything is still appending. The @@ -526,18 +607,141 @@ describe("form server: accepted sockets and raw clients", () => { yield* owner.halt(); - const released = clientLive.map((count) => count - 1); + const released = { + data: clientLive.data - 1, + error: clientLive.error - 1, + close: clientLive.close - 1, + }; - expect(first.socket.listenerCount("close")).toBe(acceptedLive - 1); + expect({ close: first.socket.listenerCount("close") }).toEqual({ close: acceptedLive - 1 }); expect(clientCounts(client)).toEqual(released); first.socket.emit("close"); client.emit("data", Buffer.from("after the client was cancelled")); client.emit("close"); - expect(first.socket.listenerCount("close")).toBe(acceptedLive - 1); + expect({ close: first.socket.listenerCount("close") }).toEqual({ close: acceptedLive - 1 }); expect(clientCounts(client)).toEqual(released); expect(received()).toBe(seen); expect(callbacks()).toBe(ran); }); }); + +function useRawPeer( + payload: string, + termination: "close" | "reset", +): Operation<{ + port: number; + terminate(): Operation; +}> { + return resource(function* (provide) { + const accepted = withResolvers(); + const sockets = new Set(); + const onError = (): void => {}; + const server = createPeer((socket) => { + sockets.add(socket); + socket.on("error", onError); + socket.write(payload); + accepted.resolve(socket); + }); + + yield* ensure(function* () { + for (const socket of sockets) { + socket.destroy(); + } + const closed = withResolvers(); + const onClose = (): void => closed.resolve(); + try { + server.on("close", onClose); + server.close(); + yield* closed.operation; + } finally { + server.off("close", onClose); + for (const socket of sockets) { + socket.off("error", onError); + } + } + }); + + const listening = withResolvers(); + const onServerError = (error: Error): void => listening.reject(error); + try { + server.on("error", onServerError); + server.listen(0, "127.0.0.1", () => listening.resolve()); + yield* listening.operation; + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("the raw peer did not listen on a TCP port"); + } + yield* provide({ + port: address.port, + *terminate() { + const socket = yield* accepted.operation; + if (termination === "reset") { + socket.resetAndDestroy(); + } else { + socket.end(); + } + }, + }); + } finally { + server.off("error", onServerError); + } + }); +} + +function* terminatedResponse( + payload: string, + termination: "close" | "reset", +): Operation<{ connection: HttpConnection; ending: string }> { + const peer = yield* useRawPeer(payload, termination); + const connection = yield* useConnection(peer.port); + yield* when(function* () { + expect(connection.receivedSoFar()).toBe(payload); + }); + yield* peer.terminate(); + const ending = yield* connection.ended; + if (ending !== "close" && !ending.startsWith("error:")) { + throw new Error(`the raw peer ended with an unknown termination: ${ending}`); + } + return { connection, ending }; +} + +describe("the raw HTTP client after connection termination", () => { + it("parses a complete response received before close", function* () { + const { connection } = yield* terminatedResponse( + "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndone", + "close", + ); + const response = yield* connection.response(); + expect(response.status).toBe(200); + expect(response.body).toBe("done"); + }); + + const terminations: ("close" | "reset")[] = ["close", "reset"]; + for (const termination of terminations) { + for (const response of [ + { name: "header", payload: "HTTP/1.1 200 OK\r\nContent-Length:" }, + { + name: "body", + payload: "HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n" + "x".repeat(300), + }, + ]) { + it(`rejects an incomplete ${response.name} after ${termination} with bounded bytes`, function* () { + const { connection, ending } = yield* terminatedResponse(response.payload, termination); + let failure: unknown; + try { + yield* connection.response(); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + const message = failure instanceof Error ? failure.message : String(failure); + expect(message).toContain(`incomplete HTTP response after ${ending};`); + expect(message).toContain(JSON.stringify(response.payload.slice(0, 160))); + expect(message).toContain(`buffered ${response.payload.length} characters`); + expect(message.length).toBeLessThan(400); + }); + } + } +}); diff --git a/scripts/lib/release-targets.ts b/scripts/lib/release-targets.ts index 65b6a8332..d19fe7a13 100644 --- a/scripts/lib/release-targets.ts +++ b/scripts/lib/release-targets.ts @@ -14,8 +14,8 @@ * needs the same answers to upgrade itself. This module owns what only the * release tooling needs on top of that table: the representative target, the * compile entrypoint, and the exact argv preparation runs. `release.yml` keeps - * the matrix list GitHub fans out over, and - * `scripts/tests/publish-workflow-membership.test.ts` holds the two to exact set + * the matrix list GitHub fans out over. The "the release matrix and its mapping" + * group in `scripts/tests/release-targets.test.ts` holds the two to exact set * equality, so a target can never be added in one place alone. */ diff --git a/scripts/tests/frozen-entry.test.ts b/scripts/tests/frozen-entry.test.ts index ef2161aa0..298c7062b 100644 --- a/scripts/tests/frozen-entry.test.ts +++ b/scripts/tests/frozen-entry.test.ts @@ -59,7 +59,14 @@ function* attempt(target: string, task: string, args: string[] = []): Operation< }).join(); const after = yield* readTextFile(lock); - expect(after).toEqual(before); + try { + expect(after).toEqual(before); + } catch (error) { + throw new Error( + `lockfile changed after ${Deno.execPath()} ${["task", task, ...args].join(" ")}\nstatus: ${result.code}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + { cause: error }, + ); + } return { code: result.code, output: `${result.stdout}${result.stderr}`, lock: after }; } diff --git a/scripts/tests/review-infrastructure.test.ts b/scripts/tests/review-infrastructure.test.ts index 3c1fefd1f..757bc00b1 100644 --- a/scripts/tests/review-infrastructure.test.ts +++ b/scripts/tests/review-infrastructure.test.ts @@ -167,7 +167,7 @@ function* runDocumentResult( document: string, components: Record, options: DocumentRunOptions = {}, -): Operation<{ ok: boolean; journal: string; text: string }> { +): Operation<{ ok: boolean; error?: Error; journal: string; text: string }> { return yield* scoped(function* () { const environment = options.env; if (environment) { @@ -209,6 +209,7 @@ function* runDocumentResult( const result = yield* execution; return { ok: result.ok, + error: result.ok ? undefined : result.error, journal: JSON.stringify(stream.snapshot()), text: result.ok && typeof result.value === "string" ? result.value.trim() : "", }; @@ -310,7 +311,7 @@ describe("review infrastructure", () => { expect(result.journal).not.toContain("secret-token"); }); - it("fails on malformed and unexpected Oxlint results, but skips empty input", function* () { + it("accepts exit 1 Oxlint diagnostics and journals only bounded fields", function* () { yield* useTempFileCompiler(); const valid = yield* runDocumentResult( @@ -342,34 +343,37 @@ describe("review infrastructure", () => { expect(valid.journal).toContain("1:no-unused-vars"); expect(valid.journal).not.toContain("raw source must not cross the boundary"); expect(valid.journal).not.toContain("unbounded payload"); + }); - expect( - yield* runDocument( - '', - { "packages/code-review-agent/src/components/OxlintDiagnostics.ts": "" }, - { - includes: [ - "packages/code-review-agent/src/documents/components", - "packages/code-review-agent/src/components", - ], - process: { exitCode: 0, stdout: "not json", stderr: "" }, - }, - ), - ).toBe(false); - expect( - yield* runDocument( + for (const scenario of [ + { + name: "malformed JSON", + process: { exitCode: 0, stdout: "not json", stderr: "" }, + diagnostic: "Oxlint returned malformed JSON", + }, + { + name: "invocation failure", + process: { exitCode: 2, stdout: "[]", stderr: "invocation failed" }, + diagnostic: "invocation failed", + }, + ]) { + it(`rejects Oxlint ${scenario.name} with its diagnostic`, function* () { + yield* useTempFileCompiler(); + const result = yield* runDocumentResult( '', { "packages/code-review-agent/src/components/OxlintDiagnostics.ts": "" }, { - includes: [ - "packages/code-review-agent/src/documents/components", - "packages/code-review-agent/src/components", - ], - process: { exitCode: 2, stdout: "[]", stderr: "invocation failed" }, + includes: OPERATIONAL_DIRS, + process: scenario.process, }, - ), - ).toBe(false); + ); + expect(result.ok).toBe(false); + expect(result.error?.message).toContain(scenario.diagnostic); + }); + } + it("skips Oxlint without starting a process for empty input", function* () { + yield* useTempFileCompiler(); let calls = 0; yield* API.Process.around({ *exec() { @@ -610,6 +614,7 @@ describe("review infrastructure", () => { : { exitCode: 0, stdout: "not json", stderr: "" }, }); expect(malformed.ok).toBe(false); + expect(malformed.error?.message).toContain("Oxlint returned malformed JSON"); const failed = yield* runDocumentResult(document, component, { ...baseOptions, @@ -617,6 +622,7 @@ describe("review infrastructure", () => { process: { exitCode: 2, stdout: "[]", stderr: "invocation failed" }, }); expect(failed.ok).toBe(false); + expect(failed.error?.message).toContain("invocation failed"); }); it("constructs ReviewContext from git and fails on git errors", function* () { diff --git a/scripts/tests/runtime-exclusions.test.ts b/scripts/tests/runtime-exclusions.test.ts index 8aedeed43..ccad4c612 100644 --- a/scripts/tests/runtime-exclusions.test.ts +++ b/scripts/tests/runtime-exclusions.test.ts @@ -1,7 +1,8 @@ /** - * The runtime scope is derived, so nothing here asserts which files run. These - * cover discovery drift and malformed exclusions. Whether an exclusion is still - * necessary is not something they can determine — see the manifest. + * These cover discovery, exclusion structure, each runtime's literal one-file + * command and launcher exit status. The applicable corpus is derived from + * discovery and the manifest; whether an exclusion remains necessary requires + * a portability check of that test. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; diff --git a/scripts/tests/runtime-tests.test.ts b/scripts/tests/runtime-tests.test.ts index f5a04f5ac..c1c2cd9ca 100644 --- a/scripts/tests/runtime-tests.test.ts +++ b/scripts/tests/runtime-tests.test.ts @@ -2,8 +2,10 @@ * What a shard runs, in what order, and what it does when one file fails. * * These drive the shard through an injected runner rather than a real one, so - * they can assert the exact argument vector and the exact ordering without - * paying for hundreds of processes. That a real child's output survives, and + * they can assert command routing and exact ordering without launching the + * corpus. Independent literal vectors and signal handling live in the + * "the one-file runner commands" and "launcher exit status" groups in + * `runtime-exclusions.test.ts`. That a real child's output survives, and * that halting a shard halts its child, are behaviors an injected runner cannot * establish — `shard-execution.test.ts` verifies those against real processes. */ @@ -112,7 +114,7 @@ describe("running a shard", () => { ); }); - it("uses each runtime's own one-file command", function* () { + it("routes each runtime through its one-file command", function* () { for (const runtime of RUNTIMES) { const runs = recorder(); yield* TestRuns.with(runs.run, () => runShardFiles(runtime, [ONE], ROOT)); diff --git a/scripts/tests/shard-execution.test.ts b/scripts/tests/shard-execution.test.ts index b2256fb99..8ffff5463 100644 --- a/scripts/tests/shard-execution.test.ts +++ b/scripts/tests/shard-execution.test.ts @@ -22,7 +22,8 @@ import { chmod } from "node:fs/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; import { until } from "effection"; -import { runShardFiles } from "../lib/runtime-tests.ts"; +import { oneFileCommand, runShardFiles } from "../lib/runtime-tests.ts"; +import type { ShardOutcome } from "../lib/runtime-tests.ts"; const REPOSITORY = new URL("../../", import.meta.url); const decoder = new TextDecoder(); @@ -153,13 +154,24 @@ describe("a shard running real children", () => { it("halts the running child's process group and starts no later file", function* () { const root = yield* workspace({ "a-slow.test.ts": SLOW, "b-later.test.ts": MARKS_LATER }); - yield* watching(); + const seen = yield* watching(); - const shard = yield* spawn(() => - runShardFiles("deno", ["tests/a-slow.test.ts", "tests/b-later.test.ts"], root), - ); + let outcome: ShardOutcome | undefined; + const shard = yield* spawn(function* () { + outcome = yield* runShardFiles( + "deno", + ["tests/a-slow.test.ts", "tests/b-later.test.ts"], + root, + ); + }); - expect(yield* waitFor(new URL("slow.marker", root))).toBe(true); + const marker = new URL("slow.marker", root); + if (!(yield* waitFor(marker))) { + const launch = oneFileCommand("deno", "tests/a-slow.test.ts"); + throw new Error( + `child did not write expected startup marker ${fileURLToPath(marker)}\ncommand: ${launch.command} ${launch.arguments.join(" ")}\noutcome: ${JSON.stringify(outcome)}\nstdout:\n${seen.stdout}\nstderr:\n${seen.stderr}`, + ); + } yield* shard.halt(); // The next assigned file was never started. @@ -227,19 +239,19 @@ function* driver( describe("a shard selection nobody assigned", () => { /** - * The count `ci.yml` declares for Deno. An index beyond it, and a count beyond - * the corpus, both parse — they are well-formed `/` — so nothing - * before the partition can reject them. + * A fixture count within the applicable corpus, independent of CI's shard + * count. An index beyond it and a count beyond the corpus both parse as + * `/`, so only the partition can reject them. */ - const DECLARED = 8; + const FIXTURE_COUNT = 8; it("refuses an in-range-looking index before any runner starts", function* () { const sentinel = yield* runnerSentinel(); - const { code, stderr } = yield* driver(sentinel, `${DECLARED + 1}/${DECLARED}`); + const { code, stderr } = yield* driver(sentinel, `${FIXTURE_COUNT + 1}/${FIXTURE_COUNT}`); expect(code).toEqual(1); - expect(stderr).toContain(`shard index ${DECLARED + 1} is outside 1..${DECLARED}`); + expect(stderr).toContain(`shard index ${FIXTURE_COUNT + 1} is outside 1..${FIXTURE_COUNT}`); expect(yield* exists(sentinel.marker)).toBe(false); }); @@ -271,7 +283,7 @@ describe("a shard selection nobody assigned", () => { it("starts the runner for a selection the partition accepts", function* () { const sentinel = yield* runnerSentinel(); - const { code } = yield* driver(sentinel, `1/${DECLARED}`); + const { code } = yield* driver(sentinel, `1/${FIXTURE_COUNT}`); expect(code).toEqual(0); expect(yield* exists(sentinel.marker)).toBe(true); diff --git a/scripts/tests/test-file-discovery.test.ts b/scripts/tests/test-file-discovery.test.ts index 5c2921757..8c98d6658 100644 --- a/scripts/tests/test-file-discovery.test.ts +++ b/scripts/tests/test-file-discovery.test.ts @@ -92,21 +92,15 @@ describe("the discovered corpus against the whole repository", () => { expect(walked).toContain("scripts/tests/test-file-discovery.test.ts"); expect(walked.length).toBeGreaterThan(100); + const selected = new Set(discovered); + const stranded = walked.filter((file) => !selected.has(file)); + // A stranded file runs under no runtime, even when every discovered test passes. + expect({ strandedFilesOutsideEveryRuntime: stranded }).toEqual({ + strandedFilesOutsideEveryRuntime: [], + }); expect(walked).toEqual(discovered); }); - /** - * Named separately from the equality above, because this is the direction - * that matters: a file the walk found and discovery did not is a test no - * runtime would ever run. - */ - it("leaves no test file outside discovery's result", function* () { - const discovered = new Set(yield* listTestFiles(ROOT)); - const stranded = (yield* repositoryTestFiles()).filter((file) => !discovered.has(file)); - - expect(stranded).toEqual([]); - }); - it("recognizes every spelling Deno runs", function* () { for (const name of ["a.test.ts", "a_test.ts", "test.ts", "a.test.tsx", "a.test.mjs"]) { expect(DENO_TEST_FILE.test(name)).toBe(true);