test(#1408): dispatch spec asserts both triggering condition lines per home shape
ci/woodpecker/pr/ci Pipeline was successful

The conditionPath() helper pinned toHaveLength(1) — a count assertion,
and the count pin was the defect: the unit's second triggering line
(brain-home shape, #1408) failed this spec (CI 2651) because it was a
second consumer of the unit template invisible to the shell suite and
the enumeration guard.

- helper returns ALL ConditionPathExists values, asserts content not count
- literal pin covers both |-prefixed lines in order
- new test: every condition line must keep the | triggering prefix
  (ported from test-fleet-units.sh's bare-ANDed-spelling refusal)
- drift-apart guard now per path: each line renders to exactly the file
  the reconciler writes for that home shape (orch-01 requirement)

Sabotage control: unit reverted to base f45928c fails exactly the three
condition tests; restored unit passes 11/11.
This commit is contained in:
2026-08-24 19:55:09 -05:00
parent 6dbc61ec46
commit a60ae792ca
@@ -211,12 +211,23 @@ describe('mosaic fleet install — roster v2', (): void => {
describe('[email protected]', (): void => { describe('[email protected]', (): void => {
const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]'); const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]');
/** The single `ConditionPathExists=` value declared by the unit template. */ /**
async function conditionPath(): Promise<string> { * Every `ConditionPathExists=` value declared by the unit template, in file
* order, `|` triggering prefix included.
*
* Until #1410 this helper pinned `toHaveLength(1)` — a count assertion, not
* a content assertion, and the count pin was itself the defect: when #1408
* required a second triggering line (the `%h/.mosaic` brain-home shape),
* this spec was a second consumer of the unit template that the shell suite
* and the enumeration guard could not see, so the fix failed here first
* (CI 2651 — the installation-documentation.spec.ts lesson again). Assert
* content per line, never count.
*/
async function conditionPaths(): Promise<string[]> {
const unit = await readFile(unitPath, 'utf8'); const unit = await readFile(unitPath, 'utf8');
const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? []; const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
expect(matches).toHaveLength(1); expect(matches.length).toBeGreaterThan(0);
return matches[0]!.slice('ConditionPathExists='.length).trim(); return matches.map((line) => line.slice('ConditionPathExists='.length).trim());
} }
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => { it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
@@ -224,7 +235,26 @@ describe('[email protected]', (): void => {
// unit (WantedBy=default.target) but does not start it, so without this // unit (WantedBy=default.target) but does not start it, so without this
// condition a reboot between `install` and the first `apply` would run // condition a reboot between `install` and the first `apply` would run
// ExecStart against an absent env file and fail every seat unit. // ExecStart against an absent env file and fail every seat unit.
expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated'); //
// Two lines since #1408: the env projection lives under %h/.config/mosaic
// on framework-home hosts and under %h/.mosaic on brain-home hosts. Both
// carry the `|` triggering prefix — systemd ANDs same-type conditions
// unless every line is triggering (then they OR), and a bare spelling
// would demand BOTH home shapes on one host, which is never true, so
// every seat would silently skip.
expect(await conditionPaths()).toEqual([
'|%h/.config/mosaic/fleet/agents/%i.env.generated',
'|%h/.mosaic/fleet/agents/%i.env.generated',
]);
});
it('refuses the bare ANDed spelling on every condition line', async (): Promise<void> => {
// Invariant ported from test-fleet-units.sh, held separately from the
// literal pin above so it survives future edits to the path set: every
// ConditionPathExists line must stay triggering (`|`).
for (const value of await conditionPaths()) {
expect(value.startsWith('|')).toBe(true);
}
}); });
/** /**
@@ -246,10 +276,18 @@ describe('[email protected]', (): void => {
*/ */
it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => { it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => {
const mosaicHome = await v2Home(); const mosaicHome = await v2Home();
const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0'); const rendered = (await conditionPaths()).map((value) =>
value.replace(/^\|/, '').replace('%h', tempHome!).replace('%i', 'coder0'),
);
// The path an installed fleet actually places for this agent. // Per home shape, the guard must render to exactly the file the
expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated')); // reconciler writes there: mosaicHome (%h/.config/mosaic) on
// framework-home hosts, %h/.mosaic on brain-home hosts (#1408) — each
// line pinned to its file, not merely present.
expect(rendered).toEqual([
join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'),
join(tempHome!, '.mosaic', 'fleet', 'agents', 'coder0.env.generated'),
]);
}); });
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => { it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {