diff --git a/src/lib/native-compose-adoption-command.ts b/src/lib/native-compose-adoption-command.ts index 4c3a1517..b8065bc8 100644 --- a/src/lib/native-compose-adoption-command.ts +++ b/src/lib/native-compose-adoption-command.ts @@ -23,6 +23,19 @@ import { } from "./projects-registry.ts"; import { run } from "./shell.ts"; +/** + * Fixed, value-free messages an adopted command ends with when it cancels or + * rewraps an unexpected refusal as `E_CONFIG_INVALID`. Diagnostics map these + * exact strings to closed tokens, so they import them instead of copying them + * (HACK-1432). + */ +export const LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES = Object.freeze({ + refused: + "Adopted Compose ownership or execution refused; original data is retained. Values omitted.", + cancelled: + "Adopted Compose operation cancelled; pending ownership is retained. Values omitted.", +}); + function unsupported(): never { throw new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", @@ -34,8 +47,7 @@ function cancelled(signal: AbortSignal) { if (signal.aborted) { throw new HackCliError({ code: "E_CONFIG_INVALID", - message: - "Adopted Compose operation cancelled; pending ownership is retained. Values omitted.", + message: LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES.cancelled, }); } } @@ -371,7 +383,7 @@ export async function tryLegacyComposeAdoptedCommand( message: error instanceof LegacyComposeAdoptedGenerationError ? error.message - : "Adopted Compose ownership or execution refused; original data is retained. Values omitted.", + : LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES.refused, ...(legacyComposeOrderedRefusal(error) ? { detail: { diff --git a/tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts b/tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts index 4bc58102..09c348f4 100644 --- a/tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts +++ b/tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts @@ -1,4 +1,5 @@ import { isRecord } from "../../../src/lib/guards.ts"; +import { LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES } from "../../../src/lib/native-compose-adoption-command.ts"; import type { LegacyComposeOrderedRefusal } from "../../../src/lib/native-compose-adoption-diagnostics.ts"; import { LegacyComposeAdoptedGenerationError } from "../../../src/lib/native-compose-adoption-generation.ts"; import { LEGACY_ADOPTION_SELECTION_MESSAGES } from "../../../src/lib/native-compose-adoption-marker.ts"; @@ -82,14 +83,8 @@ const REFUSALS: readonly (readonly [string, string])[] = [ ([token, code]) => [token, new LegacyComposeAdoptedGenerationError(code).message] as const ), - [ - "adopted-refused", - "Adopted Compose ownership or execution refused; original data is retained. Values omitted.", - ], - [ - "adopted-cancelled", - "Adopted Compose operation cancelled; pending ownership is retained. Values omitted.", - ], + ["adopted-refused", LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES.refused], + ["adopted-cancelled", LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES.cancelled], ["marker-adoption", LEGACY_ADOPTION_SELECTION_MESSAGES.adoption], ["marker-permission", LEGACY_ADOPTION_SELECTION_MESSAGES.permission], // The marker's input-access message is the shared project-input guard's. diff --git a/tests/native-compose-adoption-binding.test.ts b/tests/native-compose-adoption-binding.test.ts index f3a0848e..b94072e4 100644 --- a/tests/native-compose-adoption-binding.test.ts +++ b/tests/native-compose-adoption-binding.test.ts @@ -1,4 +1,11 @@ -import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { + afterEach, + beforeEach, + expect, + setDefaultTimeout, + spyOn, + test, +} from "bun:test"; import { chmod, mkdir, @@ -27,6 +34,32 @@ import { acquireNativeConfigImportInputs } from "../src/lib/native-config-import import { mapLegacyNativeStorageAdoption } from "../src/lib/native-config-import-plan.ts"; import { restoreEnv } from "./helpers/env.ts"; +/** + * The product's default deadline for one aggregate Docker probe + * (`createNativeComposeProbe`). Each acquisition and each freshness check here + * owns one such probe unless a test names its own `timeoutMs`. + */ +const PROBE_DEADLINE_MS = 15_000; +/** + * Every fake Docker query here is a cold Bun process, and one acquisition runs + * a dozen or more. Bun's 5 s default then budgets host process start-up rather + * than the product, and an expired test keeps running against the next test's + * shared root (HACK-1432, as HACK-1367 found for the ownership tests). The + * heaviest test here owns about eight default-deadline probes, so a slow host + * ends in the product's own closed refusal before this budget expires. + */ +const FAKE_DOCKER_TEST_TIMEOUT_MS = 8 * PROBE_DEADLINE_MS; +setDefaultTimeout(FAKE_DOCKER_TEST_TIMEOUT_MS); +/** + * A placeholder acquisition repeats bridge scans under one probe deadline + * (two extra scans with two owned bridges). The default refused one under + * heavy host load (HACK-1432), so the placeholder tests name a wider deadline + * for their clean baseline and their placeholder acquisition alike. + */ +const PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS = 3 * PROBE_DEADLINE_MS; +/** A clean acquire and resolve, then a placeholder acquire and resolve. */ +const PLACEHOLDER_RESCAN_TEST_TIMEOUT_MS = + 4 * PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS; const CANARY = "synthetic-private-adoption-canary"; const ID = "a".repeat(64); const NETWORK = "b".repeat(64); @@ -504,7 +537,9 @@ test("moving job snapshot counterexample retains identity and admits a fresh com maximumRetryCount: 0, })); } - // The maintained job fixture uses the same 20s operation / 30s case bounds. + // The maintained job fixture uses the same 20s operation bound. The case + // bound is the file's fake-Docker budget, so a slow host ends in the + // operation's own refusal rather than an unconfirmed snapshot lifetime. const deadline = Date.now() + 20_000; await executeLegacyComposeRetainedPlan({ plan, @@ -612,7 +647,7 @@ test("moving job snapshot counterexample retains identity and admits a fresh com } finally { snapshotCaseActive = false; } -}, 30_000); +}); test.each([ "foreign", @@ -629,7 +664,7 @@ test.each([ : "E_LEGACY_COMPOSE_BINDING_IDENTITY" ); expect(await Bun.file(join(root, "mutation")).exists()).toBe(false); -}, 20_000); +}); test("moving snapshot guard exhausts the original shared deadline", async () => { fixture.mode = "snapshot-guard-unstable"; @@ -650,7 +685,7 @@ test("moving snapshot guard exhausts the original shared deadline", async () => ).length ).toBeGreaterThan(2); expect(await Bun.file(join(root, "mutation")).exists()).toBe(false); -}, 10_000); +}); function networkInspections(observed: readonly string[][]): number { return observed.filter( @@ -666,37 +701,53 @@ test.each([ ] as const)( "a sandbox-less endpoint placeholder on bridge read %s is re-observed, never admitted or refused (HACK-1421)", async (ordinal, extra) => { - const clean = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const clean = await acquireLegacyComposeAdoptionBinding({ + projectRoot, + timeoutMs: PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS, + }); const original = await clean.resolveBinding({ projectRoot }); const baseline = networkInspections(await commands()); await rm(join(root, "commands")); fixture.mode = `snapshot-placeholder-${ordinal}`; await writeFile(join(root, "snapshot-network-ordinal"), "0"); await save(); - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireLegacyComposeAdoptionBinding({ + projectRoot, + timeoutMs: PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS, + }); expect(await acquired.resolveBinding({ projectRoot })).toEqual(original); expect(networkInspections(await commands())).toBe(baseline + extra); expect(JSON.stringify(original)).not.toContain("ep-"); expect(await Bun.file(join(root, "mutation")).exists()).toBe(false); }, - 60_000 + PLACEHOLDER_RESCAN_TEST_TIMEOUT_MS ); -test("a placeholder on one of two owned bridges is re-observed with both bridges (HACK-1421)", async () => { - await twoBridges(); - const clean = await acquireLegacyComposeAdoptionBinding({ projectRoot }); - const original = await clean.resolveBinding({ projectRoot }); - const baseline = networkInspections(await commands()); - await rm(join(root, "commands")); - // Reads 1-2 fix identity; read 3 is the first bridge of the accepted loop scan. - fixture.mode = "snapshot-placeholder-3"; - await writeFile(join(root, "snapshot-network-ordinal"), "0"); - await save(); - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); - expect(await acquired.resolveBinding({ projectRoot })).toEqual(original); - expect(networkInspections(await commands())).toBe(baseline + 2); - expect(await Bun.file(join(root, "mutation")).exists()).toBe(false); -}, 60_000); +test( + "a placeholder on one of two owned bridges is re-observed with both bridges (HACK-1421)", + async () => { + await twoBridges(); + const clean = await acquireLegacyComposeAdoptionBinding({ + projectRoot, + timeoutMs: PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS, + }); + const original = await clean.resolveBinding({ projectRoot }); + const baseline = networkInspections(await commands()); + await rm(join(root, "commands")); + // Reads 1-2 fix identity; read 3 is the first bridge of the accepted loop scan. + fixture.mode = "snapshot-placeholder-3"; + await writeFile(join(root, "snapshot-network-ordinal"), "0"); + await save(); + const acquired = await acquireLegacyComposeAdoptionBinding({ + projectRoot, + timeoutMs: PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS, + }); + expect(await acquired.resolveBinding({ projectRoot })).toEqual(original); + expect(networkInspections(await commands())).toBe(baseline + 2); + expect(await Bun.file(join(root, "mutation")).exists()).toBe(false); + }, + PLACEHOLDER_RESCAN_TEST_TIMEOUT_MS +); test("a persistent endpoint placeholder exhausts the original shared deadline", async () => { fixture.mode = "snapshot-placeholder-always"; @@ -709,7 +760,7 @@ test("a persistent endpoint placeholder exhausts the original shared deadline", expect(Date.now() - started).toBeLessThan(9000); // Identity read plus at least two discarded loop reads before the deadline. expect(networkInspections(await commands())).toBeGreaterThan(2); -}, 20_000); +}); test("a malformed placeholder-shaped bridge member still refuses identity without retry", async () => { fixture.mode = "snapshot-placeholder-always-malformed"; @@ -719,7 +770,25 @@ test("a malformed placeholder-shaped bridge member still refuses identity withou "E_LEGACY_COMPOSE_BINDING_IDENTITY" ); expect(networkInspections(await commands())).toBe(1); -}, 10_000); +}); + +// A duplicate `.Containers` key is never one member or one transient +// placeholder: the first bridge read refuses identity without a retry. +test.each([ + ["container ID", () => [ID, ID]], + [ + "endpoint placeholder", + () => [ID, `ep-${"d".repeat(64)}`, `ep-${"d".repeat(64)}`], + ], +] as const)("a duplicate bridge member key (%s) refuses identity without retry", async (_kind, members) => { + network().containers = members(); + await save(); + await refusal( + acquireLegacyComposeAdoptionBinding({ projectRoot }), + "E_LEGACY_COMPOSE_BINDING_IDENTITY" + ); + expect(networkInspections(await commands())).toBe(1); +}); test("moving snapshot guard rejects a selected ID on its undeclared bridge before retry", async () => { await twoBridges(); @@ -742,7 +811,7 @@ test("moving snapshot guard rejects a selected ID on its undeclared bridge befor ) ).toHaveLength(2); expect(await Bun.file(join(root, "mutation")).exists()).toBe(false); -}, 20_000); +}); test("one authored bridge binds original physical ID, internal policy and exact live aliases", async () => { await customBridge(); diff --git a/tests/native-compose-adoption-job-diagnostics.test.ts b/tests/native-compose-adoption-job-diagnostics.test.ts index e2644660..20532432 100644 --- a/tests/native-compose-adoption-job-diagnostics.test.ts +++ b/tests/native-compose-adoption-job-diagnostics.test.ts @@ -4,6 +4,7 @@ import { HackCliError, renderCliResult, } from "../src/lib/cli-result.ts"; +import { LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES } from "../src/lib/native-compose-adoption-command.ts"; import { legacyComposeOrderedError, legacyComposeOrderedRefusal, @@ -277,15 +278,6 @@ test("completed-job CLI diagnostics name a fixed refusal message by closed token ) ).toBe(`refusal=${token}`); } - expect( - refusal( - new HackCliError({ - code: "E_CONFIG_INVALID", - message: - "Adopted Compose ownership or execution refused; original data is retained. Values omitted.", - }) - ) - ).toBe("refusal=adopted-refused"); expect(refusal(new LegacyComposeAdoptionSelectionError())).toBe( "refusal=marker-adoption" ); @@ -312,3 +304,29 @@ test("completed-job CLI diagnostics name a fixed refusal message by closed token expect(messages.join("\n")).not.toContain("ENOTDIR"); expect(messages.join("\n")).not.toContain("values omitted"); }); + +// The adopted command's own fixed messages are imported, never copied, so a +// wording change cannot silently turn either token into `other` (HACK-1432). +test.each([ + ["refused", "adopted-refused"], + ["cancelled", "adopted-cancelled"], +] as const)("completed-job CLI diagnostics name the adopted command's %s message by its own token", (kind, token) => { + const messages: string[] = []; + const diagnostic = diagnostics(messages); + diagnostic.cliOutcome({ + exitCode: 1, + timedOut: false, + stdout: renderCliResult({ + result: errorResultFromUnknown({ + error: new HackCliError({ + code: "E_CONFIG_INVALID", + message: LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES[kind], + }), + }), + }), + }); + expect(messages).toEqual([ + `start-operation=restart worktree=alpha stage=cli-result exit=nonzero timed-out=no code=E_CONFIG_INVALID ordered-stage=unavailable ordered-reason=unavailable refusal=${token}`, + ]); + expect(messages.join("\n")).not.toContain("Values omitted"); +});