Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
792 lines
25 KiB
JavaScript
792 lines
25 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
mkdtemp,
|
|
mkdir,
|
|
readFile,
|
|
realpath,
|
|
writeFile
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
aggregateMetrics,
|
|
checkStagedFile,
|
|
checkStagedPresentationCssFile,
|
|
compareWithBaseline,
|
|
countEffects,
|
|
countLines,
|
|
countMemoization,
|
|
countModuleMutableGlobals,
|
|
countProviderBranches,
|
|
countRenderMirrorRefs,
|
|
countStoreCreations,
|
|
countSwallowedCatches,
|
|
isScannedSourceFile,
|
|
isScannedPresentationCssFile,
|
|
isComponentModule,
|
|
isTimerForbiddenFile,
|
|
measureFileMetrics,
|
|
parseStagedAddedLines,
|
|
stagedDiffArgs
|
|
} from "./check-agent-gui-degradation.mjs";
|
|
import { createIsolatedGitEnvironment } from "./git-environment.mjs";
|
|
|
|
const scriptPath = resolve(
|
|
dirname(fileURLToPath(import.meta.url)),
|
|
"check-agent-gui-degradation.mjs"
|
|
);
|
|
|
|
test("counts effects and memoization hooks", () => {
|
|
const source = [
|
|
"useEffect(() => {}, []);",
|
|
"useLayoutEffect(() => {}, []);",
|
|
"const value = useMemo(() => 1, []);",
|
|
"const callback = useCallback(() => 2, []);",
|
|
"export default memo(Component);"
|
|
].join("\n");
|
|
assert.equal(countEffects(source), 2);
|
|
assert.equal(countMemoization(source), 3);
|
|
});
|
|
|
|
test("counts render-time ref mirrors but ignores imperative refs", () => {
|
|
const source = [
|
|
"const usageProjectionRef = useRef(null);",
|
|
"const inputRef = useRef(input);",
|
|
"const handleHandoffRef = useRef(null);",
|
|
"const elementRef = useRef<HTMLDivElement | null>(null);",
|
|
"const timeoutRef = useRef<number | null>(null);",
|
|
"const searchInputRef = useRef<HTMLInputElement | null>(null);",
|
|
"const dragStateRef = useRef<DragState | null>(null);",
|
|
"const handledSequenceRef = useRef<number | null>(null);"
|
|
].join("\n");
|
|
assert.equal(countRenderMirrorRefs(source), 3);
|
|
});
|
|
|
|
test("counts provider behavior branches with the wide ruleset", () => {
|
|
const source = [
|
|
'if (provider === "codex") {}',
|
|
'if (data.provider !== "claude-code") {}',
|
|
"switch (provider) {",
|
|
' case "opencode":',
|
|
' case "cursor":',
|
|
" break;",
|
|
"}",
|
|
'// case "not-a-provider":',
|
|
'if (kind === "codex") {}'
|
|
].join("\n");
|
|
assert.equal(countProviderBranches(source), 4);
|
|
});
|
|
|
|
test("detects swallowed catch blocks including comment-only bodies", () => {
|
|
const source = [
|
|
"try { run(); } catch {}",
|
|
"try { run(); } catch (error) {",
|
|
" // ignored on purpose",
|
|
"}",
|
|
"try { run(); } catch (error) {",
|
|
" report(error);",
|
|
"}",
|
|
"promise.catch((error) => report(error));"
|
|
].join("\n");
|
|
assert.equal(countSwallowedCatches(source), 2);
|
|
});
|
|
|
|
test("ignores catch keyword inside strings", () => {
|
|
const source = 'const label = "catch {} nothing";';
|
|
assert.equal(countSwallowedCatches(source), 0);
|
|
});
|
|
|
|
test("counts store creations only in files importing valtio or zustand", () => {
|
|
const valtioSource = [
|
|
'import { proxy } from "valtio/vanilla";',
|
|
"const state = proxy({ open: false });"
|
|
].join("\n");
|
|
const zustandSource = [
|
|
'import { create } from "zustand";',
|
|
"const useStore = create<State>()(() => ({}));"
|
|
].join("\n");
|
|
const plainSource = "const state = proxy({ open: false });";
|
|
assert.equal(countStoreCreations(valtioSource), 1);
|
|
assert.equal(countStoreCreations(zustandSource), 1);
|
|
assert.equal(countStoreCreations(plainSource), 0);
|
|
});
|
|
|
|
test("counts module-level mutable globals at column zero only", () => {
|
|
const source = [
|
|
"let mutableSlot = null;",
|
|
"export let sharedFlag = false;",
|
|
"function scoped() {",
|
|
" let local = 1;",
|
|
" return local;",
|
|
"}",
|
|
"const stable = 2;"
|
|
].join("\n");
|
|
assert.equal(countModuleMutableGlobals(source), 2);
|
|
});
|
|
|
|
test("counts trailing line without final newline", () => {
|
|
assert.equal(countLines("a\nb\nc"), 3);
|
|
assert.equal(countLines("a\nb\nc\n"), 3);
|
|
assert.equal(countLines(""), 0);
|
|
});
|
|
|
|
test("skips test, generated, and dist files from scanning", () => {
|
|
assert.equal(
|
|
isScannedSourceFile("packages/agent/gui/agent-gui/Component.tsx"),
|
|
true
|
|
);
|
|
assert.equal(
|
|
isScannedSourceFile("packages/agent/gui/agent-gui/Component.spec.tsx"),
|
|
false
|
|
);
|
|
assert.equal(
|
|
isScannedSourceFile("packages/agent/gui/dist/Component.tsx"),
|
|
false
|
|
);
|
|
assert.equal(isScannedSourceFile("packages/agent/gui/types.d.ts"), false);
|
|
assert.equal(isScannedSourceFile("packages/agent/gui/README.md"), false);
|
|
assert.equal(
|
|
isScannedPresentationCssFile(
|
|
"packages/agent/gui/app/renderer/agentactivity.css"
|
|
),
|
|
true
|
|
);
|
|
assert.equal(
|
|
isScannedPresentationCssFile("apps/desktop/src/renderer/app.css"),
|
|
false
|
|
);
|
|
});
|
|
|
|
test("separates component modules from TSX read hooks", () => {
|
|
assert.equal(
|
|
isComponentModule("packages/agent/gui/agent-gui/View.tsx"),
|
|
true
|
|
);
|
|
assert.equal(
|
|
isComponentModule("packages/agent/gui/agent-gui/useDetailModel.tsx"),
|
|
false
|
|
);
|
|
});
|
|
|
|
test("marks engine, reducer, and selector paths as timer-forbidden", () => {
|
|
assert.equal(
|
|
isTimerForbiddenFile("packages/agent/activity-core/src/engine/loop.ts"),
|
|
true
|
|
);
|
|
assert.equal(
|
|
isTimerForbiddenFile("packages/agent/activity-core/src/queue.reducer.ts"),
|
|
true
|
|
);
|
|
assert.equal(
|
|
isTimerForbiddenFile("packages/agent/activity-core/src/selectors.ts"),
|
|
true
|
|
);
|
|
assert.equal(
|
|
isTimerForbiddenFile("packages/agent/gui/agent-gui/Component.tsx"),
|
|
false
|
|
);
|
|
});
|
|
|
|
test("routes provider branches of identity-exempt files to the identity bucket", () => {
|
|
const source = 'const icon = provider === "codex" ? codexIcon : defaultIcon;';
|
|
const exemptPath = "packages/agent/gui/agentGuiSessionProviderIconUrls.ts";
|
|
const exempt = measureFileMetrics(exemptPath, source, [exemptPath]);
|
|
assert.equal(exempt.providerBranches, 0);
|
|
assert.equal(exempt.identityProviderBranches, 1);
|
|
const regular = measureFileMetrics(exemptPath, source, []);
|
|
assert.equal(regular.providerBranches, 1);
|
|
assert.equal(regular.identityProviderBranches, 0);
|
|
});
|
|
|
|
test("aggregates effects and applies component memo and line budgets", () => {
|
|
const longFile = measureFileMetrics(
|
|
"packages/agent/gui/long.ts",
|
|
`${"line\n".repeat(801)}`,
|
|
[]
|
|
);
|
|
const shortFile = measureFileMetrics(
|
|
"packages/agent/gui/short.tsx",
|
|
`${"useMemo(() => 1, []);\n".repeat(6)}useEffect(() => {}, []);\n`,
|
|
[]
|
|
);
|
|
const metrics = aggregateMetrics({
|
|
"packages/agent/gui/long.ts": longFile,
|
|
"packages/agent/gui/short.tsx": shortFile
|
|
});
|
|
assert.deepEqual(metrics.fileLines, { "packages/agent/gui/long.ts": 801 });
|
|
assert.equal(metrics.effectCount, 1);
|
|
assert.deepEqual(metrics.componentMemoOverages, {
|
|
"packages/agent/gui/short.tsx": 6
|
|
});
|
|
});
|
|
|
|
test("flags increases as regressions and decreases as unlocked improvements", () => {
|
|
const { improvements, regressions } = compareWithBaseline(
|
|
{
|
|
fileLines: { "a.ts": 900, "c.ts": 850 },
|
|
providerBranches: 10,
|
|
setTimeoutCount: 2
|
|
},
|
|
{
|
|
fileLines: { "a.ts": 850, "b.ts": 900 },
|
|
providerBranches: 12,
|
|
setTimeoutCount: 2
|
|
}
|
|
);
|
|
assert.equal(regressions.length, 2);
|
|
assert.ok(regressions.some((entry) => /a\.ts 850 -> 900/.test(entry)));
|
|
assert.ok(
|
|
regressions.some((entry) => /c\.ts.*not in the baseline/.test(entry))
|
|
);
|
|
assert.equal(improvements.length, 2);
|
|
assert.ok(
|
|
improvements.some((entry) => /providerBranches: 12 -> 10/.test(entry))
|
|
);
|
|
assert.ok(improvements.some((entry) => /b\.ts 900 -> gone/.test(entry)));
|
|
});
|
|
|
|
test("treats new over-limit files as regressions even without baseline entry", () => {
|
|
const { regressions } = compareWithBaseline(
|
|
{ fileLines: { "new.ts": 900 } },
|
|
{ fileLines: {} }
|
|
);
|
|
assert.equal(regressions.length, 1);
|
|
assert.match(regressions[0], /not in the baseline/);
|
|
});
|
|
|
|
test("parses added lines from a unified zero-context diff", () => {
|
|
const diff = [
|
|
"diff --git a/packages/agent/gui/x.ts b/packages/agent/gui/x.ts",
|
|
"--- a/packages/agent/gui/x.ts",
|
|
"+++ b/packages/agent/gui/x.ts",
|
|
"@@ -10,0 +11,2 @@ context",
|
|
"+const added = 1;",
|
|
"+const alsoAdded = 2;",
|
|
"@@ -20 +23 @@ context",
|
|
"-const removed = 3;",
|
|
"+const replaced = 4;"
|
|
].join("\n");
|
|
const addedLinesByFile = parseStagedAddedLines(diff);
|
|
assert.deepEqual(addedLinesByFile.get("packages/agent/gui/x.ts"), [
|
|
{ content: "const added = 1;", line: 11 },
|
|
{ content: "const alsoAdded = 2;", line: 12 },
|
|
{ content: "const replaced = 4;", line: 23 }
|
|
]);
|
|
});
|
|
|
|
test("staged diff compares a merge index against the incoming parent", () => {
|
|
assert.deepEqual(stagedDiffArgs(null).slice(0, 3), [
|
|
"diff",
|
|
"--cached",
|
|
"-U0"
|
|
]);
|
|
assert.deepEqual(stagedDiffArgs("merge-head").slice(0, 4), [
|
|
"diff",
|
|
"--cached",
|
|
"merge-head",
|
|
"-U0"
|
|
]);
|
|
});
|
|
|
|
test("staged check flags uncommented timers and accepts timing-comment timers", () => {
|
|
const stagedContent = [
|
|
"// timing: batch daemon events into one frame",
|
|
"setTimeout(flush, 33);",
|
|
"setTimeout(retry, 100);"
|
|
].join("\n");
|
|
const violations = checkStagedFile({
|
|
addedLines: [
|
|
{ content: "setTimeout(flush, 33);", line: 2 },
|
|
{ content: "setTimeout(retry, 100);", line: 3 }
|
|
],
|
|
identityExemptFiles: [],
|
|
relativePath: "packages/agent/gui/agent-gui/helper.ts",
|
|
stagedContent
|
|
});
|
|
assert.equal(violations.length, 1);
|
|
assert.equal(violations[0].rule, "no-sync-timer");
|
|
assert.equal(violations[0].line, 3);
|
|
});
|
|
|
|
test("staged check rejects timers in timer-forbidden paths despite comments", () => {
|
|
const stagedContent = [
|
|
"// timing: not acceptable here",
|
|
"setTimeout(step, 5);"
|
|
].join("\n");
|
|
const violations = checkStagedFile({
|
|
addedLines: [{ content: "setTimeout(step, 5);", line: 2 }],
|
|
identityExemptFiles: [],
|
|
relativePath: "packages/agent/activity-core/src/engine/loop.ts",
|
|
stagedContent
|
|
});
|
|
assert.equal(violations.length, 1);
|
|
assert.equal(violations[0].rule, "no-sync-timer");
|
|
});
|
|
|
|
test("staged check requires lifecycle reasons for presentation schedulers", () => {
|
|
const stagedContent = [
|
|
"// presentation-work: one visible-frame commit while dragging",
|
|
"requestAnimationFrame(commitVisibleDrag);",
|
|
"const observer = new ResizeObserver(measureEverything);"
|
|
].join("\n");
|
|
const violations = checkStagedFile({
|
|
addedLines: [
|
|
{ content: "requestAnimationFrame(commitVisibleDrag);", line: 2 },
|
|
{
|
|
content: "const observer = new ResizeObserver(measureEverything);",
|
|
line: 3
|
|
}
|
|
],
|
|
identityExemptFiles: [],
|
|
relativePath: "packages/agent/gui/agent-gui/View.tsx",
|
|
stagedContent
|
|
});
|
|
|
|
assert.equal(violations.length, 1);
|
|
assert.equal(violations[0].rule, "presentation-scheduler-lifecycle");
|
|
assert.equal(violations[0].line, 3);
|
|
});
|
|
|
|
test("staged check requires lifecycle reasons for inline compositor hints", () => {
|
|
const line = 'const style = { willChange: "transform" };';
|
|
const violations = checkStagedFile({
|
|
addedLines: [{ content: line, line: 1 }],
|
|
identityExemptFiles: [],
|
|
relativePath: "packages/agent/gui/agent-gui/View.tsx",
|
|
stagedContent: line
|
|
});
|
|
|
|
assert.equal(violations.length, 1);
|
|
assert.equal(violations[0].rule, "inline-compositor-lifecycle");
|
|
});
|
|
|
|
test("staged CSS requires exact reasons and rejects transition all", () => {
|
|
const stagedContent = [
|
|
".viewer {",
|
|
" will-change: transform;",
|
|
" transition: all 180ms ease;",
|
|
"}"
|
|
].join("\n");
|
|
const withoutReason = checkStagedPresentationCssFile({
|
|
addedLines: [
|
|
{ content: " will-change: transform;", line: 2 },
|
|
{ content: " transition: all 180ms ease;", line: 3 }
|
|
],
|
|
presentationHintReasons: {},
|
|
relativePath: "packages/agent/gui/view.css",
|
|
stagedContent
|
|
});
|
|
assert.deepEqual(withoutReason.map(({ rule }) => rule).sort(), [
|
|
"no-transition-all",
|
|
"presentation-hint-lifecycle"
|
|
]);
|
|
|
|
const fingerprint =
|
|
"packages/agent/gui/view.css | .viewer | will-change: transform";
|
|
const withReason = checkStagedPresentationCssFile({
|
|
addedLines: [{ content: " will-change: transform;", line: 2 }],
|
|
presentationHintReasons: {
|
|
[fingerprint]: "Viewer exists only while its modal is open"
|
|
},
|
|
relativePath: "packages/agent/gui/view.css",
|
|
stagedContent: ".viewer {\n will-change: transform;\n}"
|
|
});
|
|
assert.deepEqual(withReason, []);
|
|
});
|
|
|
|
test("staged check flags new provider branches outside identity-exempt files", () => {
|
|
const line = 'if (provider === "codex") { enablePlanMode(); }';
|
|
const flagged = checkStagedFile({
|
|
addedLines: [{ content: line, line: 1 }],
|
|
identityExemptFiles: [],
|
|
relativePath: "packages/agent/gui/agent-gui/behavior.ts",
|
|
stagedContent: line
|
|
});
|
|
assert.equal(flagged.length, 1);
|
|
assert.equal(flagged[0].rule, "provider-branch-freeze");
|
|
|
|
const exempt = checkStagedFile({
|
|
addedLines: [{ content: line, line: 1 }],
|
|
identityExemptFiles: [
|
|
"packages/agent/gui/agentGuiSessionProviderIconUrls.ts"
|
|
],
|
|
relativePath: "packages/agent/gui/agentGuiSessionProviderIconUrls.ts",
|
|
stagedContent: line
|
|
});
|
|
assert.equal(exempt.length, 0);
|
|
});
|
|
|
|
test("staged check flags store creation in component files", () => {
|
|
const stagedContent = [
|
|
'import { proxy } from "valtio/vanilla";',
|
|
"const railState = proxy({ hovered: null });"
|
|
].join("\n");
|
|
const violations = checkStagedFile({
|
|
addedLines: [
|
|
{ content: "const railState = proxy({ hovered: null });", line: 2 }
|
|
],
|
|
identityExemptFiles: [],
|
|
relativePath: "packages/agent/gui/agent-gui/View.tsx",
|
|
stagedContent
|
|
});
|
|
assert.equal(violations.length, 1);
|
|
assert.equal(violations[0].rule, "no-store-in-view");
|
|
});
|
|
|
|
test("staged check rejects component cache overages with layer guidance", () => {
|
|
const stagedContent = "useMemo(() => 1, []);\n".repeat(6);
|
|
const violations = checkStagedFile({
|
|
addedLines: [{ content: "useMemo(() => 1, []);", line: 6 }],
|
|
identityExemptFiles: [],
|
|
relativePath: "packages/agent/gui/agent-gui/View.tsx",
|
|
stagedContent
|
|
});
|
|
assert.equal(violations.length, 1);
|
|
assert.equal(violations[0].rule, "component-memo-budget");
|
|
assert.match(violations[0].message, /engine selectors\/read hooks/);
|
|
});
|
|
|
|
test("staged check rejects render ref mirrors with engine guidance", () => {
|
|
const line = "const usageProjectionRef = useRef(null);";
|
|
const violations = checkStagedFile({
|
|
addedLines: [{ content: line, line: 1 }],
|
|
identityExemptFiles: [],
|
|
relativePath: "packages/agent/gui/agent-gui/View.tsx",
|
|
stagedContent: line
|
|
});
|
|
assert.equal(violations.length, 1);
|
|
assert.equal(violations[0].rule, "no-render-ref-mirror");
|
|
assert.match(violations[0].message, /engine\/controller/);
|
|
});
|
|
|
|
test("staged check flags direct useSyncExternalStore outside the binding file", () => {
|
|
const line = "const value = useSyncExternalStore(subscribe, getSnapshot);";
|
|
const flagged = checkStagedFile({
|
|
addedLines: [{ content: line, line: 1 }],
|
|
identityExemptFiles: [],
|
|
relativePath: "packages/agent/gui/agent-gui/Component.tsx",
|
|
stagedContent: line
|
|
});
|
|
assert.equal(flagged.length, 1);
|
|
assert.equal(flagged[0].rule, "single-subscription-binding");
|
|
|
|
const binding = checkStagedFile({
|
|
addedLines: [{ content: line, line: 1 }],
|
|
identityExemptFiles: [],
|
|
relativePath: "packages/agent/gui/shared/engine/useEngineSelector.ts",
|
|
stagedContent: line
|
|
});
|
|
assert.equal(binding.length, 0);
|
|
});
|
|
|
|
test("staged check flags swallowed catches intersecting added lines", () => {
|
|
const stagedContent = ["try {", " run();", "} catch {", "}"].join("\n");
|
|
const violations = checkStagedFile({
|
|
addedLines: [{ content: "} catch {", line: 3 }],
|
|
identityExemptFiles: [],
|
|
relativePath: "packages/agent/gui/agent-gui/helper.ts",
|
|
stagedContent
|
|
});
|
|
assert.equal(violations.length, 1);
|
|
assert.equal(violations[0].rule, "no-swallowed-catch");
|
|
});
|
|
|
|
test("staged check flags new module-level mutable globals", () => {
|
|
const line = "let activeRuntimeSlot = null;";
|
|
const violations = checkStagedFile({
|
|
addedLines: [{ content: line, line: 1 }],
|
|
identityExemptFiles: [],
|
|
relativePath: "packages/agent/gui/runtimeRegistry.ts",
|
|
stagedContent: line
|
|
});
|
|
assert.equal(violations.length, 1);
|
|
assert.equal(violations[0].rule, "no-module-mutable-global");
|
|
});
|
|
|
|
test("full mode generates a baseline and then detects regressions", async () => {
|
|
const workspaceRoot = await mkdtemp(join(tmpdir(), "agent-gui-degradation-"));
|
|
const sourcePath = join(
|
|
workspaceRoot,
|
|
"packages/agent/gui/agent-gui/sample.ts"
|
|
);
|
|
await mkdir(dirname(sourcePath), { recursive: true });
|
|
await writeFile(sourcePath, 'if (provider === "codex") {}\n');
|
|
const baselinePath = join(workspaceRoot, "baseline/agent-gui.json");
|
|
|
|
const update = runScript(workspaceRoot, baselinePath, ["--update-baseline"]);
|
|
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
|
|
const baseline = JSON.parse(await readFile(baselinePath, "utf8"));
|
|
assert.equal(baseline.metrics.providerBranches, 1);
|
|
|
|
const pass = runScript(workspaceRoot, baselinePath, []);
|
|
assert.equal(pass.status, 0, pass.stderr || pass.stdout);
|
|
assert.match(pass.stdout, /degradation check passed/);
|
|
|
|
await writeFile(
|
|
sourcePath,
|
|
'if (provider === "codex") {}\nif (provider === "cursor") {}\n'
|
|
);
|
|
const regression = runScript(workspaceRoot, baselinePath, []);
|
|
assert.notEqual(regression.status, 0);
|
|
assert.match(regression.stderr, /providerBranches: 1 -> 2/);
|
|
|
|
await writeFile(sourcePath, "const clean = 1;\n");
|
|
const improvement = runScript(workspaceRoot, baselinePath, []);
|
|
assert.notEqual(improvement.status, 0);
|
|
assert.match(improvement.stderr, /--update-baseline/);
|
|
});
|
|
|
|
test("full mode refuses to baseline presentation hints without lifecycle reasons", async () => {
|
|
const workspaceRoot = await mkdtemp(join(tmpdir(), "agent-gui-degradation-"));
|
|
const stylesheetPath = join(
|
|
workspaceRoot,
|
|
"packages/agent/gui/app/renderer/agentactivity.css"
|
|
);
|
|
await mkdir(dirname(stylesheetPath), { recursive: true });
|
|
await writeFile(
|
|
stylesheetPath,
|
|
`
|
|
.viewer {
|
|
will-change: transform;
|
|
}
|
|
.agent-gui-node__layout[data-agent-gui-visible="false"]
|
|
:where(*, *::before, *::after) {
|
|
animation-play-state: paused !important;
|
|
}
|
|
.agent-gui-node__layout[data-agent-gui-visible="false"] {
|
|
content-visibility: hidden;
|
|
}
|
|
.agent-gui-node__layout[data-agent-gui-active="false"]
|
|
.agent-gui-node__composer-prompt-tip-track,
|
|
.agent-gui-node__layout[data-agent-gui-active="false"]
|
|
.agent-gui-node__composer-prompt-tip-item {
|
|
animation: none;
|
|
will-change: auto;
|
|
}
|
|
`
|
|
);
|
|
const baselinePath = join(workspaceRoot, "baseline/agent-gui.json");
|
|
await mkdir(dirname(baselinePath), { recursive: true });
|
|
await writeFile(
|
|
baselinePath,
|
|
`${JSON.stringify({
|
|
identityExemptFiles: [],
|
|
metrics: {},
|
|
presentationHintReasons: {}
|
|
})}\n`
|
|
);
|
|
|
|
const missingReason = runScript(workspaceRoot, baselinePath, [
|
|
"--update-baseline"
|
|
]);
|
|
assert.notEqual(missingReason.status, 0);
|
|
assert.match(missingReason.stderr, /missing reason/);
|
|
|
|
await writeFile(
|
|
baselinePath,
|
|
`${JSON.stringify({
|
|
identityExemptFiles: [],
|
|
metrics: {},
|
|
presentationHintReasons: {
|
|
"packages/agent/gui/app/renderer/agentactivity.css | .viewer | will-change: transform":
|
|
"Viewer is mounted only while its modal is open"
|
|
}
|
|
})}\n`
|
|
);
|
|
const update = runScript(workspaceRoot, baselinePath, ["--update-baseline"]);
|
|
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
const pass = runScript(workspaceRoot, baselinePath, []);
|
|
assert.equal(pass.status, 0, pass.stderr || pass.stdout);
|
|
});
|
|
|
|
test("staged mode flags violations on staged added lines end to end", async () => {
|
|
const workspaceRoot = await mkdtemp(join(tmpdir(), "agent-gui-degradation-"));
|
|
runFixtureGit(workspaceRoot, ["init", "--quiet"]);
|
|
await assertFixtureGitRoot(workspaceRoot);
|
|
|
|
const sourcePath = join(
|
|
workspaceRoot,
|
|
"packages/agent/gui/agent-gui/helper.ts"
|
|
);
|
|
await mkdir(dirname(sourcePath), { recursive: true });
|
|
await writeFile(sourcePath, "export const before = 1;\n");
|
|
runFixtureGit(workspaceRoot, ["add", "."]);
|
|
runFixtureGit(workspaceRoot, [
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"-c",
|
|
"user.name=Test",
|
|
"commit",
|
|
"--quiet",
|
|
"-m",
|
|
"init"
|
|
]);
|
|
|
|
await writeFile(
|
|
sourcePath,
|
|
"export const before = 1;\nsetTimeout(retry, 100);\n"
|
|
);
|
|
runFixtureGit(workspaceRoot, ["add", "."]);
|
|
|
|
const baselinePath = join(workspaceRoot, "baseline/agent-gui.json");
|
|
const result = runScript(workspaceRoot, baselinePath, ["--staged"]);
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /no-sync-timer/);
|
|
|
|
await writeFile(
|
|
sourcePath,
|
|
"export const before = 1;\n// timing: retry with visible reason\nsetTimeout(retry, 100);\n"
|
|
);
|
|
runFixtureGit(workspaceRoot, ["add", "."]);
|
|
const pass = runScript(workspaceRoot, baselinePath, ["--staged"]);
|
|
assert.equal(pass.status, 0, pass.stderr || pass.stdout);
|
|
assert.doesNotMatch(pass.stderr, /fatal:/u);
|
|
});
|
|
|
|
test("staged mode accepts a CSS hint reason only after the baseline is staged", async () => {
|
|
const workspaceRoot = await mkdtemp(join(tmpdir(), "agent-gui-degradation-"));
|
|
runFixtureGit(workspaceRoot, ["init", "--quiet"]);
|
|
await assertFixtureGitRoot(workspaceRoot);
|
|
|
|
const stylesheetPath = join(
|
|
workspaceRoot,
|
|
"packages/agent/gui/app/renderer/agentactivity.css"
|
|
);
|
|
const baselinePath = join(
|
|
workspaceRoot,
|
|
"tools/degradation-baseline/agent-gui.json"
|
|
);
|
|
await mkdir(dirname(stylesheetPath), { recursive: true });
|
|
await mkdir(dirname(baselinePath), { recursive: true });
|
|
const requiredCss = `
|
|
.agent-gui-node__layout[data-agent-gui-visible="false"]
|
|
:where(*, *::before, *::after) {
|
|
animation-play-state: paused !important;
|
|
}
|
|
.agent-gui-node__layout[data-agent-gui-visible="false"] {
|
|
content-visibility: hidden;
|
|
}
|
|
.agent-gui-node__layout[data-agent-gui-active="false"]
|
|
.agent-gui-node__composer-prompt-tip-track,
|
|
.agent-gui-node__layout[data-agent-gui-active="false"]
|
|
.agent-gui-node__composer-prompt-tip-item {
|
|
animation: none;
|
|
will-change: auto;
|
|
}
|
|
`;
|
|
await writeFile(stylesheetPath, requiredCss);
|
|
await writeFile(
|
|
baselinePath,
|
|
`${JSON.stringify({
|
|
identityExemptFiles: [],
|
|
metrics: {},
|
|
presentationHintReasons: {}
|
|
})}\n`
|
|
);
|
|
runFixtureGit(workspaceRoot, ["add", "."]);
|
|
runFixtureGit(workspaceRoot, [
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"-c",
|
|
"user.name=Test",
|
|
"commit",
|
|
"--quiet",
|
|
"-m",
|
|
"init"
|
|
]);
|
|
|
|
await writeFile(
|
|
stylesheetPath,
|
|
`${requiredCss}\n.viewer {\n will-change: transform;\n}\n`
|
|
);
|
|
await writeFile(
|
|
baselinePath,
|
|
`${JSON.stringify({
|
|
identityExemptFiles: [],
|
|
metrics: {},
|
|
presentationHintReasons: {
|
|
"packages/agent/gui/app/renderer/agentactivity.css | .viewer | will-change: transform":
|
|
"Viewer is mounted only while its modal is open"
|
|
}
|
|
})}\n`
|
|
);
|
|
runFixtureGit(workspaceRoot, ["add", stylesheetPath]);
|
|
|
|
const unstagedReason = runScript(workspaceRoot, baselinePath, ["--staged"]);
|
|
assert.notEqual(unstagedReason.status, 0);
|
|
assert.match(unstagedReason.stderr, /presentation-hint-lifecycle/);
|
|
|
|
runFixtureGit(workspaceRoot, ["add", baselinePath]);
|
|
const stagedReason = runScript(workspaceRoot, baselinePath, ["--staged"]);
|
|
assert.equal(
|
|
stagedReason.status,
|
|
0,
|
|
stagedReason.stderr || stagedReason.stdout
|
|
);
|
|
});
|
|
|
|
test("full mode fails with instructions when the baseline is missing", async () => {
|
|
const workspaceRoot = await mkdtemp(join(tmpdir(), "agent-gui-degradation-"));
|
|
await mkdir(join(workspaceRoot, "packages/agent/gui"), { recursive: true });
|
|
const baselinePath = join(workspaceRoot, "baseline/agent-gui.json");
|
|
const result = runScript(workspaceRoot, baselinePath, []);
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /baseline is missing/);
|
|
});
|
|
|
|
test("full mode rejects identity exemptions whose files no longer exist", async () => {
|
|
const workspaceRoot = await mkdtemp(join(tmpdir(), "agent-gui-degradation-"));
|
|
const baselinePath = join(workspaceRoot, "baseline/agent-gui.json");
|
|
await mkdir(dirname(baselinePath), { recursive: true });
|
|
await writeFile(
|
|
baselinePath,
|
|
`${JSON.stringify({
|
|
identityExemptFiles: ["packages/agent/gui/providerTargets.ts"],
|
|
metrics: {}
|
|
})}\n`
|
|
);
|
|
|
|
const result = runScript(workspaceRoot, baselinePath, []);
|
|
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /stale identity exemptions/);
|
|
assert.match(result.stderr, /providerTargets\.ts/);
|
|
});
|
|
|
|
function runScript(workspaceRoot, baselinePath, args) {
|
|
return spawnSync(process.execPath, [scriptPath, ...args], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...createIsolatedGitEnvironment(workspaceRoot),
|
|
TUTTI_AGENT_GUI_DEGRADATION_BASELINE: baselinePath,
|
|
TUTTI_WORKSPACE_ROOT: workspaceRoot
|
|
}
|
|
});
|
|
}
|
|
|
|
function runFixtureGit(workspaceRoot, args) {
|
|
const result = spawnSync("git", args, {
|
|
cwd: workspaceRoot,
|
|
encoding: "utf8",
|
|
env: createIsolatedGitEnvironment(workspaceRoot)
|
|
});
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
return result;
|
|
}
|
|
|
|
async function assertFixtureGitRoot(workspaceRoot) {
|
|
const result = runFixtureGit(workspaceRoot, [
|
|
"rev-parse",
|
|
"--absolute-git-dir"
|
|
]);
|
|
assert.equal(
|
|
await realpath(result.stdout.trim()),
|
|
await realpath(join(workspaceRoot, ".git"))
|
|
);
|
|
}
|