* test(#2856): add failing-first suite for the live-dom-uat capability Binds the approved triage shape before any of it exists: - containment — the execute:wave:post hook must not render unless workflow.live_dom_uat is true AND the capability resolves active (fail-closed on a missing state entry, and on a non-boolean value) - criterion 4 — agents/gsd-executor.md carries no browser MCP family; asserted as an absence, which is the only way it is observable - Hyrum guard — the pre-existing mcp__playwright__* branch must stay outside the key-gated block, or upgrading silently removes working automated UI verification for every current Playwright-MCP user - parity — the browser glob list now lives in two surfaces (agent frontmatter + workflow detection block); the assertion fails if either gains or loses a family without the other Red by construction: the capability, agent and workflow block do not exist yet. Verified on the remote runner. Refs #2856 * enhance(#2856): add default-off live-DOM UAT capability A phase whose acceptance criteria needed a live DOM could not be finished by the agent that executed it: gsd-executor carries no browser tools, so it correctly returned checkpoint:human-action even though the work was not human-only, just tool-less. Every such phase degraded to "executed, then finished by hand in the orchestrator", and autonomous: false could not distinguish "a human must judge this" from "the executor lacks the tool". Implements the shape approved at triage, not the one reported. The executor's tools: line is NOT widened, in any configuration: for a first-party agent the static list is the only control that exists (ADR-1244 D2, ADR-857 D4, no per-dispatch override). Instead one default-off capability owns the key, the agent, and the step: - capabilities/live-dom-uat/ — activationKey workflow.live_dom_uat (boolean, default false), one additive step at execute:wave:post (onError: skip, gates: []), so it can never halt a wave - agents/gsd-dom-verifier.md — the only GSD agent carrying browser MCP globs, in its own tools: line, with no Bash - verify-work automated_ui_verification — a gsd:live-dom-families block naming both new families AND the key; presence alone never activates Two independent fail-closed gates: isCapabilityActive renders a hook only on state.active === true, plus the step's own `when`. The pre-existing mcp__playwright__* branch keeps the gating it already had and stays outside the new block. Pulling it behind a default-off key would have silently removed working automated UI verification from every current Playwright-MCP user on upgrade. Also closes a host gap this surfaced: execute:wave:post dispatched only contribution + gate, so ANY registered step was declared and silently never run — exactly the single-kind hand-roll loop-hook-dispatch.md names. Step 5.75 now dispatches every kind == "step". The browser-profile lock is tolerated, not coordinated: --isolated is a flag on the operator's own MCP-server registration that GSD neither launches nor parameterizes, so the verifier reports could_not_look / profile_locked, names the flag, and stops. DOM-VERIFY.md keeps could_not_look and nothing_to_report distinct behind a closed reason enum — collapsing them is the ambiguous-run-notes defect reported. Verified on the remote runner. Closes #2856 * fix(#2856): apply review findings from the orthogonal passes Correctness pass (blocker): - delete detectionBlockIsCrlfSafe. It was pass-always: it read the file, replaced LF with CRLF, then indexOf'd marker strings that contain no newline, so the replacement could not change the result and the assertion could never fail for the reason it stated. There is no real CRLF risk on this surface either — the gsd:live-dom-families block has no parser, only human and agent readers. Deleted rather than replaced, per the repo's pass-always-test rule. Isolated security pass (two minors, both real): - execute-phase.md step 5.75: this change is what first activates kind == "step" dispatch at execute:wave:post, which newly opens the ref.command shell path at that loop point. Our own step uses ref.agent and never touches it, but the door is now open, so the step-dispatch line carries the same in-context validate-before-shell warning the sibling gate-dispatch line directly below it already carries. - gsd-dom-verifier: quoted page text in DOM-VERIFY.md is attacker influenced. Require it wrapped in inline code or a fence, kept short, and never left reading as a directive to the next reader. Verified on the remote runner. Refs #2856 * fix(#2856): settle the new-agent roster ripple Checkpoint 2 returned 28 failures, none in the new suite — all of them the guards that exist to make adding an agent a deliberate act. Each is a real boundary that had to move: - docs/AGENTS.md: Tools row must copy the frontmatter verbatim (#2526), so the browser globs lose their backticks; primary-agent counts 21->22, roster 33/34->34/35, Verifiers category 1->2 - docs/INVENTORY.md: roster completeness requires every agents/gsd-*.md to be classified exactly once - gsd-dom-verifier: add the anti-heredoc instruction and the commented hooks: frontmatter pattern both agent gates require - gsd-core/bin/shared/model-catalog.json: every shipped agent needs a profile entry (#3229) - copilot-install / kilo-upgrades / qwen-upgrades: expected agent list and the 34->35 roster boundary - execute-wave-post-gate-pipeline-e2e: execute:wave:post legitimately carries one step now. Asserted as an exact shape — one step, capId live-dom-uat, ref.agent gsd-dom-verifier, onError skip — so it stays a real guard against accidental change rather than being relaxed Two findings worth naming: mcp-tool-inheritance (#2526) rejected the agent for documenting mcp__playwright__* while its tools: line withholds it — a dead instruction that invites the agent to claim a path it cannot take. The prose now names the Playwright MCP family without the dispatchable token, in both the agent and the capability fragment. runtime-launcher-parity rejected the new gsd_run call: each fenced block is its own shell, so a workflow step file invoking gsd_run needs its own canonical preamble. Propagated with scripts/sync-runtime-launcher.cjs. That script also normalizes explore.md, which is unrelated pre-existing drift the parity check tolerates, so it is reverted to keep this diff scoped. The emitted-drift ack supersedes the spent #3370 entry for execute-phase.md — it is merged into next, so its ripple is absorbed at the base and it can no longer clear anything. That is the same supersede the #3370 entry itself performed on the spent #3324 fragment. Its unrelated execute-plan.md entry is untouched. Verified on the remote runner. Refs #2856 * fix(#2856): drop the stale emitted-drift ack entry The automated-ui-verification.md entry was written speculatively rather than from a reported growth, and the check names that precisely: an ack "written or reworded in THIS diff, but nothing here needed it, so it explains nothing". The growth tier keys on the bare filename as it appears under gsd-core/workflows/ or agents/. automated-ui-verification.md is nested under verify-work/steps/, so it was never in the tracked set — only execute-phase.md was ever reported, both before and after the launcher preamble landed. Only ack what the check actually reports. Verified on the remote runner. Refs #2856 * chore(#2856): backfill changeset pr number pr:0 -> 3716. The placeholder fails both changeset-lint (fail_invalid_fragment) and docs-lint (fail_malformed_fragment) by design and can only be resolved once the PR number exists. Both now report ok against GITHUB_BASE_REF=next. Refs #2856 --------- Co-authored-by: sim <sim@local>
449 lines
18 KiB
JavaScript
449 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* live-dom-uat capability — #2856
|
|
*
|
|
* Enhancement shape approved at triage: browser MCP reach is NOT added to
|
|
* agents/gsd-executor.md. Instead a default-off capability owns one boolean
|
|
* config key, one purpose-built agent that carries the browser globs in its
|
|
* OWN tools: line, and one additive step hook at execute:wave:post.
|
|
*
|
|
* Risk zone under test (in order):
|
|
* 1. Containment — no browser reach when workflow.live_dom_uat is off.
|
|
* 2. No regression of the pre-existing mcp__playwright__* path in verify-work.
|
|
* 3. The key must not parse-and-do-nothing.
|
|
*
|
|
* Rules honoured: behavioural assertions against the real resolver + real
|
|
* generated registry; shipped-.md reads only where the deployed text IS the
|
|
* runtime contract (agent/workflow markdown), each site carrying an adjacent
|
|
* allow-test-rule marker.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const fc = require('./helpers/fast-check-setup.cjs');
|
|
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
|
|
|
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
|
const { resolveLoopHooks } = require('../gsd-core/bin/lib/loop-resolver.cjs');
|
|
const { isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs');
|
|
const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
|
|
const CAP_ID = 'live-dom-uat';
|
|
const KEY = 'workflow.live_dom_uat';
|
|
const AGENT = 'gsd-dom-verifier';
|
|
const POINT = 'execute:wave:post';
|
|
|
|
/** The browser MCP families this capability grants — the single source of truth. */
|
|
const BROWSER_GLOBS = ['mcp__chrome-devtools__*', 'mcp__claude-in-chrome__*'];
|
|
|
|
const AGENT_PATH = path.join(REPO_ROOT, 'agents', `${AGENT}.md`);
|
|
const EXECUTOR_PATH = path.join(REPO_ROOT, 'agents', 'gsd-executor.md');
|
|
const UI_VERIFY_PATH = path.join(
|
|
REPO_ROOT, 'gsd-core', 'workflows', 'verify-work', 'steps', 'automated-ui-verification.md',
|
|
);
|
|
const MANIFEST_PATH = path.join(REPO_ROOT, 'capabilities', CAP_ID, 'capability.json');
|
|
|
|
const CANONICAL_POINTS = [
|
|
'discuss:pre', 'discuss:post', 'plan:pre', 'plan:post',
|
|
'execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post',
|
|
'verify:pre', 'verify:post', 'ship:pre', 'ship:post',
|
|
];
|
|
|
|
/**
|
|
* Synthetic registry carrying our real step declaration at execute:wave:post.
|
|
* Mirrors the fixture shape in tests/loop-hooks-empty-points-e2e.test.cjs so the
|
|
* resolver sees the same envelope production hands it.
|
|
*/
|
|
function buildRegistry() {
|
|
const byLoopPoint = {};
|
|
for (const p of CANONICAL_POINTS) byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
|
|
byLoopPoint[POINT] = {
|
|
steps: [{
|
|
capId: CAP_ID,
|
|
when: KEY,
|
|
ref: { agent: AGENT },
|
|
fragment: { path: 'fragments/execute-wave-post.md' },
|
|
produces: ['DOM-VERIFY.md'],
|
|
consumes: ['PLAN.md'],
|
|
onError: 'skip',
|
|
}],
|
|
contributions: [],
|
|
gates: [],
|
|
};
|
|
return { byLoopPoint, configSchema: { [KEY]: { default: false } } };
|
|
}
|
|
|
|
/** Resolve our hook out of a result, or undefined. */
|
|
function ourHook(result) {
|
|
return result.activeHooks.find((h) => h.capId === CAP_ID);
|
|
}
|
|
|
|
function readManifest() {
|
|
return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
}
|
|
|
|
/** Extract the `tools:` declaration from an agent's frontmatter as a token list. */
|
|
function agentTools(agentPath) {
|
|
const src = fs.readFileSync(agentPath, 'utf8');
|
|
const nl = src.indexOf('\n---', 3);
|
|
const fm = src.slice(0, nl < 0 ? src.length : nl);
|
|
const lines = fm.split(/\r?\n/);
|
|
const idx = lines.findIndex((l) => l.startsWith('tools:'));
|
|
if (idx === -1) return [];
|
|
const inline = lines[idx].slice('tools:'.length).trim();
|
|
if (inline) return inline.split(',').map((s) => s.trim()).filter(Boolean);
|
|
const out = [];
|
|
for (let i = idx + 1; i < lines.length; i += 1) {
|
|
const m = /^\s*-\s*(.+?)\s*$/.exec(lines[i]);
|
|
if (!m) break;
|
|
out.push(m[1]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Every mcp__ glob an agent declares, minus the context7 pair every agent may carry. */
|
|
function browserGlobsOf(agentPath) {
|
|
return agentTools(agentPath)
|
|
.filter((t) => t.startsWith('mcp__'))
|
|
.filter((t) => !t.includes('context7'))
|
|
.sort();
|
|
}
|
|
|
|
/**
|
|
* The browser families named inside the workflow's key-gated live-DOM block.
|
|
* The block is delimited by an HTML comment so this assertion has a stable
|
|
* anchor and cannot drift onto unrelated prose elsewhere in the file.
|
|
*/
|
|
function liveDomBlock() {
|
|
const src = fs.readFileSync(UI_VERIFY_PATH, 'utf8');
|
|
const open = src.indexOf('<!-- gsd:live-dom-families -->');
|
|
const close = src.indexOf('<!-- /gsd:live-dom-families -->');
|
|
assert.ok(open !== -1, 'automated-ui-verification.md must open a gsd:live-dom-families block');
|
|
assert.ok(close > open, 'automated-ui-verification.md must close the gsd:live-dom-families block');
|
|
return src.slice(open, close);
|
|
}
|
|
|
|
// ─── 1. Registry projection ──────────────────────────────────────────────────
|
|
|
|
describe('live-dom-uat: capability manifest and registry projection', () => {
|
|
test('manifestDeclaresDefaultOffBooleanKeyOwnedByThisCapability', () => {
|
|
const m = readManifest();
|
|
assert.equal(m.id, CAP_ID);
|
|
assert.equal(m.activationKey, KEY, 'capability must be gated by its own activation key');
|
|
const slice = m.config[KEY];
|
|
assert.equal(slice.type, 'boolean', 'array/object slices are dropped as malformed');
|
|
assert.equal(slice.default, false, 'the key is default-OFF — this is the containment');
|
|
});
|
|
|
|
test('manifestOwnsTheAgentAndDeclaresOneAdditiveStep', () => {
|
|
const m = readManifest();
|
|
assert.deepStrictEqual(m.agents, [AGENT]);
|
|
assert.equal(m.steps.length, 1);
|
|
const step = m.steps[0];
|
|
assert.equal(step.point, POINT);
|
|
assert.deepStrictEqual(step.ref, { agent: AGENT });
|
|
assert.equal(step.when, KEY, 'step must carry the same key as the capability');
|
|
assert.equal(step.onError, 'skip', 'a step hook is additive and must never halt the host');
|
|
});
|
|
|
|
test('manifestDeclaresNoGatesSoItCannotBlockTheHost', () => {
|
|
const m = readManifest();
|
|
assert.deepStrictEqual(m.gates, [], 'live-DOM verification is advisory, never blocking');
|
|
});
|
|
|
|
test('fragmentPathResolvesOnDisk', () => {
|
|
const m = readManifest();
|
|
const rel = m.steps[0].fragment.path;
|
|
const abs = path.join(REPO_ROOT, 'capabilities', CAP_ID, rel);
|
|
assert.ok(fs.statSync(abs).isFile(), `declared fragment must exist: ${rel}`);
|
|
assert.ok(fs.statSync(abs).size > 0, 'fragment must not be empty');
|
|
});
|
|
|
|
test('manifestVersionMatchesSiblingSweep', () => {
|
|
const sibling = JSON.parse(
|
|
fs.readFileSync(path.join(REPO_ROOT, 'capabilities', 'research', 'capability.json'), 'utf8'),
|
|
);
|
|
assert.equal(readManifest().version, sibling.version,
|
|
'capability versions move as one release-time sweep');
|
|
});
|
|
|
|
test('generatedRegistryProjectsKeyAgentAndLoopPoint', () => {
|
|
assert.equal(realRegistry.configSchema[KEY].owner, CAP_ID);
|
|
assert.equal(realRegistry.configSchema[KEY].type, 'boolean');
|
|
assert.equal(realRegistry.configSchema[KEY].default, false);
|
|
assert.ok(realRegistry.byAgent[AGENT], 'registry must index the agent');
|
|
const steps = realRegistry.byLoopPoint[POINT].steps;
|
|
assert.ok(steps.some((s) => s.capId === CAP_ID), `registry must carry a ${CAP_ID} step at ${POINT}`);
|
|
});
|
|
|
|
test('exactlyOneCapabilityOwnsTheKey', () => {
|
|
const owners = Object.entries(realRegistry.configSchema)
|
|
.filter(([k]) => k === KEY)
|
|
.map(([, v]) => v.owner);
|
|
assert.deepStrictEqual(owners, [CAP_ID], 'a config key may be owned by exactly one capability');
|
|
});
|
|
});
|
|
|
|
// ─── 2. Containment: hook activation ─────────────────────────────────────────
|
|
|
|
describe('live-dom-uat: the hook does not render unless the key is on', () => {
|
|
const ACTIVE = { [CAP_ID]: { enabled: true, active: true } };
|
|
|
|
test('hookAbsentWhenKeyDefaultsOff', () => {
|
|
const r = resolveLoopHooks({
|
|
point: POINT, registry: buildRegistry(), config: {}, capabilityStatesById: ACTIVE,
|
|
});
|
|
assert.equal(ourHook(r), undefined, 'absent key must not activate browser reach');
|
|
});
|
|
|
|
test('hookAbsentWhenKeyExplicitlyFalse', () => {
|
|
const r = resolveLoopHooks({
|
|
point: POINT,
|
|
registry: buildRegistry(),
|
|
config: { workflow: { live_dom_uat: false } },
|
|
capabilityStatesById: ACTIVE,
|
|
});
|
|
assert.equal(ourHook(r), undefined);
|
|
});
|
|
|
|
test('hookRendersWhenKeyOnAndCapabilityActive', () => {
|
|
const r = resolveLoopHooks({
|
|
point: POINT,
|
|
registry: buildRegistry(),
|
|
config: { workflow: { live_dom_uat: true } },
|
|
capabilityStatesById: ACTIVE,
|
|
});
|
|
const hook = ourHook(r);
|
|
assert.ok(hook, 'key on + capability active must render the step');
|
|
assert.deepStrictEqual(hook.ref, { agent: AGENT });
|
|
assert.equal(hook.kind, 'step');
|
|
});
|
|
|
|
test('resolvedStepIsAdditiveAndNeverHalts', () => {
|
|
const r = resolveLoopHooks({
|
|
point: POINT,
|
|
registry: buildRegistry(),
|
|
config: { workflow: { live_dom_uat: true } },
|
|
capabilityStatesById: ACTIVE,
|
|
});
|
|
const hook = ourHook(r);
|
|
assert.equal(hook.onError, 'skip');
|
|
assert.notEqual(hook.blocking, true, 'a step hook must never be blocking');
|
|
});
|
|
|
|
test('hookAbsentWhenCapabilityConfigDisabled', () => {
|
|
const r = resolveLoopHooks({
|
|
point: POINT,
|
|
registry: buildRegistry(),
|
|
config: { workflow: { live_dom_uat: true } },
|
|
capabilityStatesById: { [CAP_ID]: { enabled: true, active: false } },
|
|
});
|
|
assert.equal(ourHook(r), undefined,
|
|
'installed-but-config-disabled must not render — the gate is fail-closed');
|
|
});
|
|
|
|
test('hookAbsentWhenCapabilityStateEntryMissing', () => {
|
|
const r = resolveLoopHooks({
|
|
point: POINT,
|
|
registry: buildRegistry(),
|
|
config: { workflow: { live_dom_uat: true } },
|
|
capabilityStatesById: { 'some-other-cap': { enabled: true, active: true } },
|
|
});
|
|
assert.equal(ourHook(r), undefined, 'a missing state entry is fail-closed, not permissive');
|
|
});
|
|
|
|
test('withNoCapabilityStateMapTheKeyAloneStillGates', () => {
|
|
// Production sometimes omits capabilityStatesById entirely; the `when` guard
|
|
// is then the only gate and must still hold. Asserted for BOTH polarities so
|
|
// this pins gating rather than the absence of a map.
|
|
const off = resolveLoopHooks({ point: POINT, registry: buildRegistry(), config: {} });
|
|
assert.equal(ourHook(off), undefined);
|
|
const on = resolveLoopHooks({
|
|
point: POINT, registry: buildRegistry(), config: { workflow: { live_dom_uat: true } },
|
|
});
|
|
assert.ok(ourHook(on), 'key on with no state map must still render');
|
|
});
|
|
});
|
|
|
|
// ─── 3. The key must not parse-and-do-nothing ────────────────────────────────
|
|
|
|
describe('live-dom-uat: config key acceptance and coercion', () => {
|
|
test('configKeyIsRecognisedByConfigValidation', () => {
|
|
assert.equal(isValidConfigKey(KEY), true,
|
|
'an unregistered key is silently dropped — the key would parse and do nothing');
|
|
});
|
|
|
|
test('configSetAcceptsAndPersistsTheKey', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const result = runGsdTools(`config-set ${KEY} true`, tmpDir);
|
|
assert.ok(result.success, `config-set must accept ${KEY}: ${result.error}`);
|
|
|
|
const cfg = JSON.parse(fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf8'));
|
|
assert.strictEqual(cfg.workflow?.live_dom_uat, true, 'value must persist as a boolean');
|
|
});
|
|
|
|
test('configSetAcceptsFalse', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const result = runGsdTools(`config-set ${KEY} false`, tmpDir);
|
|
assert.ok(result.success, `config-set must accept false: ${result.error}`);
|
|
const cfg = JSON.parse(fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf8'));
|
|
assert.strictEqual(cfg.workflow?.live_dom_uat, false);
|
|
});
|
|
|
|
test('configSetRejectsANonBooleanValue', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const result = runGsdTools(`config-set ${KEY} banana`, tmpDir);
|
|
assert.ok(!result.success, 'config-set must reject a non-boolean for a boolean slice');
|
|
});
|
|
|
|
// The containment proof that matters: a value hand-written into config.json,
|
|
// bypassing config-set's validation entirely. loadConfig's federated merge
|
|
// type-checks the slice and substitutes the slice default, so the resolver
|
|
// only ever sees a real boolean. Asserted end-to-end through the real
|
|
// registry, because resolveLoopHooks alone gates on truthiness by design —
|
|
// type safety is the config layer's job, and this proves the layers compose.
|
|
for (const [label, value] of [
|
|
['stringTrue', '"true"'],
|
|
['stringFalse', '"false"'],
|
|
['numberOne', '1'],
|
|
['numberZero', '0'],
|
|
['nullValue', 'null'],
|
|
['emptyArray', '[]'],
|
|
['emptyObject', '{}'],
|
|
]) {
|
|
test(`handWrittenNonBooleanNeverActivates_${label}`, (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
`{"workflow":{"live_dom_uat":${value}}}`,
|
|
);
|
|
|
|
const cfg = loadConfig(tmpDir);
|
|
assert.strictEqual(cfg.workflow?.live_dom_uat, false,
|
|
`${label} must resolve to the slice default, not survive as a truthy value`);
|
|
|
|
const r = resolveLoopHooks({
|
|
point: POINT,
|
|
registry: realRegistry,
|
|
config: cfg,
|
|
cwd: tmpDir,
|
|
capabilityStatesById: { [CAP_ID]: { enabled: true, active: true } },
|
|
});
|
|
assert.equal(ourHook(r), undefined, `${label} must not activate browser reach`);
|
|
});
|
|
}
|
|
|
|
test('absentKeyResolvesToTheSchemaDefault', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const cfg = loadConfig(tmpDir);
|
|
assert.strictEqual(cfg.workflow?.live_dom_uat, false,
|
|
'with no config written at all, the slice default is what the loop sees');
|
|
});
|
|
|
|
test('property: no hand-written non-boolean ever activates the hook', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
|
|
fc.assert(
|
|
fc.property(
|
|
fc.oneof(
|
|
fc.constant(null),
|
|
fc.constant(0),
|
|
fc.constant(''),
|
|
fc.string(),
|
|
fc.integer(),
|
|
fc.array(fc.integer()),
|
|
fc.dictionary(fc.string(), fc.integer()),
|
|
),
|
|
(value) => {
|
|
fs.writeFileSync(configPath, JSON.stringify({ workflow: { live_dom_uat: value } }));
|
|
const cfg = loadConfig(tmpDir);
|
|
if (cfg.workflow?.live_dom_uat !== false) return false;
|
|
const r = resolveLoopHooks({
|
|
point: POINT,
|
|
registry: realRegistry,
|
|
config: cfg,
|
|
cwd: tmpDir,
|
|
capabilityStatesById: { [CAP_ID]: { enabled: true, active: true } },
|
|
});
|
|
return ourHook(r) === undefined;
|
|
},
|
|
),
|
|
{ numRuns: 50 },
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── 4. Shipped-text contracts ───────────────────────────────────────────────
|
|
|
|
describe('live-dom-uat: shipped agent and workflow text', () => {
|
|
test('domVerifierCarriesTheBrowserGlobsInItsOwnToolsLine', () => {
|
|
// allow-test-rule: source-text-is-the-product (#2856)
|
|
// An agent's frontmatter IS its tool grant at runtime; there is no API to
|
|
// enumerate a not-yet-spawned agent's permissions.
|
|
assert.deepStrictEqual(browserGlobsOf(AGENT_PATH), [...BROWSER_GLOBS].sort());
|
|
});
|
|
|
|
test('executorSurfaceIsUnchangedInEveryConfiguration', () => {
|
|
// allow-test-rule: source-text-is-the-product (#2856)
|
|
// Criterion 4 of the approved shape is an ABSENCE, observable only in the
|
|
// deployed agent text. This is the guard against the shape that was refused.
|
|
const tools = agentTools(EXECUTOR_PATH);
|
|
for (const glob of BROWSER_GLOBS) {
|
|
assert.ok(!tools.includes(glob),
|
|
`gsd-executor must never carry ${glob} — triage refused widening its surface`);
|
|
}
|
|
assert.ok(!tools.some((t) => t.startsWith('mcp__') && !t.includes('context7')),
|
|
'gsd-executor may carry no MCP family beyond context7');
|
|
});
|
|
|
|
test('browserGlobParityAcrossAgentAndWorkflowSurfaces', () => {
|
|
// allow-test-rule: source-text-is-the-product (#2856)
|
|
// Two surfaces now carry one list (DEFECT class: generative fix divergence).
|
|
// This fails if either surface gains or loses a family without the other.
|
|
const block = liveDomBlock();
|
|
const named = BROWSER_GLOBS.filter((g) => block.includes(g)).sort();
|
|
assert.deepStrictEqual(named, browserGlobsOf(AGENT_PATH),
|
|
'the workflow detection block and the agent tools line must name the same families');
|
|
});
|
|
|
|
test('newFamilyBranchRequiresBothPresenceAndTheKey', () => {
|
|
// allow-test-rule: source-text-is-the-product (#2856)
|
|
const block = liveDomBlock();
|
|
assert.ok(block.includes(KEY),
|
|
'the new-family branch must name the config key — presence alone is not sufficient');
|
|
for (const glob of BROWSER_GLOBS) {
|
|
assert.ok(block.includes(glob), `the new-family branch must name ${glob}`);
|
|
}
|
|
});
|
|
|
|
test('playwrightBranchIsNotGatedOnTheNewKey', () => {
|
|
// allow-test-rule: source-text-is-the-product (#2856)
|
|
// Hyrum's Law regression guard: mcp__playwright__* works today on presence +
|
|
// ui-phase-active. Pulling it behind a default-off key would silently remove
|
|
// working behaviour on upgrade. The playwright path must sit OUTSIDE the
|
|
// key-gated block entirely.
|
|
const src = fs.readFileSync(UI_VERIFY_PATH, 'utf8');
|
|
const block = liveDomBlock();
|
|
assert.ok(src.includes('mcp__playwright__'), 'the playwright path must still exist');
|
|
assert.ok(!block.includes('mcp__playwright__'),
|
|
'playwright must not be inside the key-gated block — that would be a silent regression');
|
|
});
|
|
});
|