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
497 lines
13 KiB
JavaScript
497 lines
13 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import test from "node:test";
|
|
import {
|
|
buildLaneInputFingerprint,
|
|
gitFingerprintMaxBuffer,
|
|
mergeLaneResults,
|
|
resolveRetryPushReady,
|
|
selectFailedOnlyLanes
|
|
} from "./run-check-changed-cache.mjs";
|
|
import {
|
|
isPackageBuildRelevant,
|
|
parseCliArgs,
|
|
printSummary,
|
|
runLanes,
|
|
selectExistingLintFiles
|
|
} from "./run-check-changed.mjs";
|
|
import {
|
|
isAgentActivityRuntimeBoundaryRelevant,
|
|
isRendererBoundaryRelevant
|
|
} from "./repository-checks.mjs";
|
|
import { createIsolatedGitEnvironment } from "./git-environment.mjs";
|
|
|
|
isolateProcessGitEnvironment(createIsolatedGitEnvironment(tmpdir()));
|
|
|
|
test("parseCliArgs rejects unknown and invalid options", () => {
|
|
assert.throws(() => parseCliArgs(["--push-reddy"]), /unknown option/u);
|
|
assert.throws(
|
|
() => parseCliArgs(["--max-parallel", "nope"]),
|
|
/positive integer/u
|
|
);
|
|
assert.throws(() => parseCliArgs(["--base"]), /requires a value/u);
|
|
});
|
|
|
|
test("parseCliArgs accepts pnpm separators and explicit values", () => {
|
|
assert.deepEqual(
|
|
parseCliArgs([
|
|
"--",
|
|
"--dry-run",
|
|
"--push-ready",
|
|
"--base",
|
|
"origin/trunk",
|
|
"--max-parallel",
|
|
"2",
|
|
"--tail-lines",
|
|
"40"
|
|
]),
|
|
{
|
|
baseRef: "origin/trunk",
|
|
dryRun: true,
|
|
failedOnly: false,
|
|
maxParallel: 2,
|
|
pushReady: true,
|
|
tailLines: 40,
|
|
verbose: false
|
|
}
|
|
);
|
|
});
|
|
|
|
test("package builds exclude test-only inputs", () => {
|
|
const manifestIsNotRelevant = () => false;
|
|
|
|
for (const file of [
|
|
"packages/agent/gui/controller.spec.ts",
|
|
"packages/agent/gui/vitest.config.ts",
|
|
"packages/agent/gui/vitest.shared.setup.ts",
|
|
"packages/agent/gui/package.json"
|
|
]) {
|
|
assert.equal(
|
|
isPackageBuildRelevant(file, manifestIsNotRelevant),
|
|
false,
|
|
file
|
|
);
|
|
}
|
|
assert.equal(
|
|
isPackageBuildRelevant(
|
|
"packages/agent/gui/index.ts",
|
|
manifestIsNotRelevant
|
|
),
|
|
true
|
|
);
|
|
assert.equal(
|
|
isPackageBuildRelevant("packages/agent/gui/package.json", () => true),
|
|
true
|
|
);
|
|
});
|
|
|
|
test("lane input fingerprints change only with their own inputs", () => {
|
|
const workspaceRoot = mkdtempSync(join(tmpdir(), "check-fingerprint-"));
|
|
runFixtureGit(workspaceRoot, ["init", "--quiet"]);
|
|
const sourcePath = join(workspaceRoot, "source.ts");
|
|
const helperPath = join(workspaceRoot, "helper.ts");
|
|
writeFileSync(sourcePath, "export const value = 1;\n");
|
|
writeFileSync(helperPath, "export const helper = 1;\n");
|
|
runFixtureGit(workspaceRoot, ["add", "source.ts", "helper.ts"]);
|
|
runFixtureGit(workspaceRoot, [
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"-c",
|
|
"user.name=Test",
|
|
"commit",
|
|
"--quiet",
|
|
"-m",
|
|
"init"
|
|
]);
|
|
const sourceLane = {
|
|
key: "source",
|
|
label: "source",
|
|
command: ["check", "source.ts"],
|
|
inputFiles: ["source.ts"]
|
|
};
|
|
const helperLane = {
|
|
key: "helper",
|
|
label: "helper",
|
|
command: ["check", "helper.ts"],
|
|
inputFiles: ["helper.ts"]
|
|
};
|
|
const initialSource = buildLaneInputFingerprint({
|
|
baseRef: "HEAD",
|
|
lane: sourceLane,
|
|
workspaceRoot
|
|
});
|
|
const initialHelper = buildLaneInputFingerprint({
|
|
baseRef: "HEAD",
|
|
lane: helperLane,
|
|
workspaceRoot
|
|
});
|
|
|
|
writeFileSync(sourcePath, "export const value = 2;\n");
|
|
const workingSource = buildLaneInputFingerprint({
|
|
baseRef: "HEAD",
|
|
lane: sourceLane,
|
|
workspaceRoot
|
|
});
|
|
const workingHelper = buildLaneInputFingerprint({
|
|
baseRef: "HEAD",
|
|
lane: helperLane,
|
|
workspaceRoot
|
|
});
|
|
assert.notEqual(workingSource, initialSource);
|
|
assert.equal(workingHelper, initialHelper);
|
|
|
|
runFixtureGit(workspaceRoot, ["add", "source.ts"]);
|
|
writeFileSync(sourcePath, "export const value = 3;\n");
|
|
const staged = buildLaneInputFingerprint({
|
|
baseRef: "HEAD",
|
|
lane: sourceLane,
|
|
workspaceRoot
|
|
});
|
|
runFixtureGit(workspaceRoot, ["reset", "--quiet"]);
|
|
const unstaged = buildLaneInputFingerprint({
|
|
baseRef: "HEAD",
|
|
lane: sourceLane,
|
|
workspaceRoot
|
|
});
|
|
assert.notEqual(staged, unstaged);
|
|
});
|
|
|
|
test("lane input fingerprints support diffs larger than the spawnSync default buffer", () => {
|
|
const workspaceRoot = mkdtempSync(join(tmpdir(), "check-large-fingerprint-"));
|
|
runFixtureGit(workspaceRoot, ["init", "--quiet"]);
|
|
const sourcePath = join(workspaceRoot, "source.txt");
|
|
writeFileSync(sourcePath, "initial\n");
|
|
runFixtureGit(workspaceRoot, ["add", "source.txt"]);
|
|
runFixtureGit(workspaceRoot, [
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"-c",
|
|
"user.name=Test",
|
|
"commit",
|
|
"--quiet",
|
|
"-m",
|
|
"init"
|
|
]);
|
|
writeFileSync(sourcePath, `${"x".repeat(2 * 1024 * 1024)}\n`);
|
|
|
|
const fingerprint = buildLaneInputFingerprint({
|
|
baseRef: "HEAD",
|
|
lane: {
|
|
key: "large-source",
|
|
label: "large-source",
|
|
command: ["check", "source.txt"],
|
|
inputFiles: ["source.txt"]
|
|
},
|
|
workspaceRoot
|
|
});
|
|
|
|
assert.match(fingerprint, /^[a-f0-9]{64}$/u);
|
|
});
|
|
|
|
test("lane fingerprint buffer covers repository-managed binary source assets", () => {
|
|
assert.equal(gitFingerprintMaxBuffer, 128 * 1024 * 1024);
|
|
});
|
|
|
|
test("failed-only reuses only passed lanes with unchanged inputs", () => {
|
|
const currentLanes = [
|
|
laneFixture("passed-unchanged", "same"),
|
|
laneFixture("failed-unchanged", "same"),
|
|
laneFixture("passed-changed", "after"),
|
|
laneFixture("new-lane", "new")
|
|
];
|
|
const summary = {
|
|
laneFingerprintVersion: 1,
|
|
results: [
|
|
resultFixture("passed-unchanged", "same", 0),
|
|
resultFixture("failed-unchanged", "same", 1),
|
|
resultFixture("passed-changed", "before", 0),
|
|
resultFixture("removed-lane", "same", 0)
|
|
]
|
|
};
|
|
|
|
const selection = selectFailedOnlyLanes(currentLanes, summary);
|
|
|
|
assert.deepEqual(
|
|
selection.lanesToRun.map((lane) => lane.key),
|
|
["failed-unchanged", "passed-changed", "new-lane"]
|
|
);
|
|
assert.deepEqual(
|
|
selection.reusedResults.map((result) => result.key),
|
|
["passed-unchanged"]
|
|
);
|
|
});
|
|
|
|
test("failed-only rejects summaries without lane fingerprints", () => {
|
|
assert.throws(
|
|
() => selectFailedOnlyLanes([], { results: [] }),
|
|
/legacy failed-lane state/u
|
|
);
|
|
});
|
|
|
|
test("failed-only inherits push-ready lanes from the previous run", () => {
|
|
assert.equal(resolveRetryPushReady(false, { pushReady: true }), true);
|
|
assert.equal(resolveRetryPushReady(false, { pushReady: false }), false);
|
|
assert.equal(resolveRetryPushReady(true, { pushReady: false }), true);
|
|
});
|
|
|
|
test("failed-only keeps reused results available for the next retry", () => {
|
|
const currentLanes = [
|
|
laneFixture("passed", "same"),
|
|
laneFixture("retried", "fixed")
|
|
];
|
|
const firstSelection = selectFailedOnlyLanes(currentLanes, {
|
|
laneFingerprintVersion: 1,
|
|
results: [
|
|
resultFixture("passed", "same", 0),
|
|
resultFixture("retried", "broken", 1)
|
|
]
|
|
});
|
|
const mergedResults = mergeLaneResults(
|
|
currentLanes,
|
|
[resultFixture("retried", "fixed", 0)],
|
|
firstSelection.reusedResults
|
|
);
|
|
|
|
const nextSelection = selectFailedOnlyLanes(currentLanes, {
|
|
laneFingerprintVersion: 1,
|
|
results: mergedResults
|
|
});
|
|
|
|
assert.deepEqual(nextSelection.lanesToRun, []);
|
|
assert.deepEqual(
|
|
nextSelection.reusedResults.map((result) => result.key),
|
|
["passed", "retried"]
|
|
);
|
|
});
|
|
|
|
test("renderer boundary lane covers renderer and checker changes", () => {
|
|
for (const file of [
|
|
"apps/desktop/src/renderer/src/features/workspace-workbench/services/coordinator.ts",
|
|
"tools/scripts/check-renderer-feature-boundaries.mjs",
|
|
"tools/scripts/check-renderer-feature-boundaries.test.mjs"
|
|
]) {
|
|
assert.equal(isRendererBoundaryRelevant(file), true, file);
|
|
}
|
|
assert.equal(
|
|
isRendererBoundaryRelevant("apps/desktop/src/main/index.ts"),
|
|
false
|
|
);
|
|
});
|
|
|
|
test("activity runtime boundary lane covers package, desktop adapter, and checker changes", () => {
|
|
for (const file of [
|
|
"packages/agent/gui/AgentGUI.tsx",
|
|
"packages/agent/activity-core/src/engine/engine.ts",
|
|
"apps/desktop/src/renderer/src/features/workspace-agent/services/runtime.ts",
|
|
"apps/desktop/src/renderer/src/features/workspace-workbench/ui/Agent.tsx",
|
|
"tools/scripts/check-agent-activity-runtime-boundaries.mjs",
|
|
"tools/scripts/check-agent-activity-runtime-boundaries.test.mjs"
|
|
]) {
|
|
assert.equal(isAgentActivityRuntimeBoundaryRelevant(file), true, file);
|
|
}
|
|
assert.equal(
|
|
isAgentActivityRuntimeBoundaryRelevant(
|
|
"apps/desktop/src/renderer/src/features/workspace-file-manager/file.ts"
|
|
),
|
|
false
|
|
);
|
|
});
|
|
|
|
test("selectExistingLintFiles drops deleted lint targets", () => {
|
|
const changedFiles = [
|
|
"packages/foo/src/live.ts",
|
|
"packages/foo/src/deleted.ts",
|
|
"packages/foo/README.md"
|
|
];
|
|
|
|
const lintFiles = selectExistingLintFiles(
|
|
changedFiles,
|
|
(file) => file !== "packages/foo/src/deleted.ts"
|
|
);
|
|
|
|
assert.deepEqual(lintFiles, ["packages/foo/src/live.ts"]);
|
|
});
|
|
|
|
test("selectExistingLintFiles keeps existing lintable paths", () => {
|
|
const changedFiles = [
|
|
"apps/desktop/src/main/index.ts",
|
|
"packages/foo/src/helper.mjs"
|
|
];
|
|
|
|
const lintFiles = selectExistingLintFiles(changedFiles, () => true);
|
|
|
|
assert.deepEqual(lintFiles, changedFiles);
|
|
});
|
|
|
|
test("runLanes preserves lane indexes without relying on outer scope", async () => {
|
|
const runDirectory = mkdtempSync(join(tmpdir(), "run-check-changed-"));
|
|
const lanes = [
|
|
{
|
|
key: "lane-b",
|
|
label: "lane-b",
|
|
command: [process.execPath, "-e", "setTimeout(() => {}, 10)"]
|
|
},
|
|
{
|
|
key: "lane-a",
|
|
label: "lane-a",
|
|
command: [process.execPath, "-e", ""]
|
|
}
|
|
];
|
|
|
|
const results = await runLanes(lanes, runDirectory);
|
|
|
|
assert.deepEqual(
|
|
results.map((result) => result.index),
|
|
[0, 1]
|
|
);
|
|
assert.deepEqual(
|
|
results.map((result) => result.key),
|
|
["lane-b", "lane-a"]
|
|
);
|
|
});
|
|
|
|
test("runLanes spawns Windows command shims through the shell", async () => {
|
|
if (process.platform !== "win32") {
|
|
return;
|
|
}
|
|
|
|
const runDirectory = mkdtempSync(join(tmpdir(), "run-check-changed-"));
|
|
const shimPath = join(runDirectory, "shim.cmd");
|
|
writeFileSync(shimPath, "@echo off\r\nexit /b 0\r\n");
|
|
|
|
const results = await runLanes(
|
|
[
|
|
{
|
|
key: "windows-shim",
|
|
label: "windows-shim",
|
|
command: [shimPath]
|
|
}
|
|
],
|
|
runDirectory
|
|
);
|
|
|
|
assert.deepEqual(
|
|
results.map((result) => result.exitCode),
|
|
[0]
|
|
);
|
|
});
|
|
|
|
test("runLanes serializes lanes that write the same generated assets", async () => {
|
|
const runDirectory = mkdtempSync(join(tmpdir(), "run-check-changed-"));
|
|
const markerPath = join(runDirectory, "marker.txt");
|
|
const firstProgram =
|
|
'const fs=require("node:fs"); fs.appendFileSync(process.argv[1], "first-start\\n"); setTimeout(() => { fs.appendFileSync(process.argv[1], "first-end\\n"); }, 30);';
|
|
const secondProgram =
|
|
'const fs=require("node:fs"); if (!fs.readFileSync(process.argv[1], "utf8").includes("first-end")) process.exit(1);';
|
|
const lanes = [
|
|
{
|
|
key: "generate:builtin-apps",
|
|
label: "generate:builtin-apps",
|
|
serialGroup: "tuttid-builtin-assets",
|
|
command: [process.execPath, "-e", firstProgram, markerPath]
|
|
},
|
|
{
|
|
key: "build:go",
|
|
label: "build:go",
|
|
serialGroup: "tuttid-builtin-assets",
|
|
command: [process.execPath, "-e", secondProgram, markerPath]
|
|
}
|
|
];
|
|
|
|
const results = await runLanes(lanes, runDirectory);
|
|
|
|
assert.deepEqual(
|
|
results.map((result) => result.exitCode),
|
|
[0, 0]
|
|
);
|
|
assert.equal(readFileSync(markerPath, "utf8"), "first-start\nfirst-end\n");
|
|
});
|
|
|
|
test("printSummary includes rerun hint for failures", () => {
|
|
const errors = [];
|
|
const originalError = console.error;
|
|
console.error = (...args) => {
|
|
errors.push(args.join(" "));
|
|
};
|
|
|
|
try {
|
|
printSummary(
|
|
[
|
|
{
|
|
command: ["pnpm", "lint"],
|
|
durationMs: 10,
|
|
exitCode: 1,
|
|
index: 0,
|
|
key: "lint:changed",
|
|
label: "lint:changed",
|
|
logPath: "/tmp/lint.log",
|
|
logPathRelative: ".tmp/check-runs/example/lint.log"
|
|
}
|
|
],
|
|
[
|
|
{
|
|
command: ["pnpm", "lint"],
|
|
durationMs: 10,
|
|
exitCode: 1,
|
|
index: 0,
|
|
key: "lint:changed",
|
|
label: "lint:changed",
|
|
logPath: "/tmp/lint.log",
|
|
logPathRelative: ".tmp/check-runs/example/lint.log"
|
|
}
|
|
],
|
|
10,
|
|
"/tmp/check-runs/example"
|
|
);
|
|
} finally {
|
|
console.error = originalError;
|
|
}
|
|
|
|
assert.match(
|
|
errors.at(-1) ?? "",
|
|
/Rerun failed lanes with: pnpm check:changed -- --failed-only/u
|
|
);
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
function laneFixture(key, inputFingerprint) {
|
|
return {
|
|
command: ["check", key],
|
|
inputFiles: [`${key}.ts`],
|
|
inputFingerprint,
|
|
key,
|
|
label: key
|
|
};
|
|
}
|
|
|
|
function resultFixture(key, inputFingerprint, exitCode) {
|
|
return {
|
|
...laneFixture(key, inputFingerprint),
|
|
durationMs: 10,
|
|
exitCode,
|
|
index: 0,
|
|
logPath: `/tmp/${key}.log`,
|
|
logPathRelative: `.tmp/${key}.log`
|
|
};
|
|
}
|
|
|
|
function isolateProcessGitEnvironment(nextEnvironment) {
|
|
for (const name of Object.keys(process.env)) {
|
|
if (!Object.hasOwn(nextEnvironment, name)) {
|
|
delete process.env[name];
|
|
}
|
|
}
|
|
Object.assign(process.env, nextEnvironment);
|
|
}
|