mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
* chore(ci): audit packaging costs and repeated test execution Read-only audit: Linux packaging and smokes consume 12-14 minutes; all three CLI shards repeat the 294-test source suite and build the server twice. Preserve the 17 required contexts, move packaging to main, run CLI source tests once, and consolidate repeated E2E setup into journeys. Historical logs and the complete audit were reported outside the repository. * ci: move packaging to main and remove repeated CLI suites PR runners were rebuilding distribution artifacts and executing the entire CLI source suite on all three E2E shards. Keep packaging coverage after merge, let the local E2E runner own its dependency build, and execute source tests once in an existing required check. Root typecheck already validates plugin examples. * test(ci): consolidate journeys and remove repeated startup work Run user journeys with shared setup, retain one Hub CLI contract, and weight the existing Playwright shards using observed costs. Remove implementation-only and ad-hoc measurement checks. Await root-watch closure and publish creation failure commands atomically so teardown and failure injection have completion boundaries. * test(hub): remove incidental observation and speculative shutdown changes Hub control RPCs need a connected client without an agent observation. Preserve the failed-create subscription contract and remove product shutdown changes whose Windows cleanup cause was not reproduced. * fix(client): clear the scheduled retry when a connect attempt opens a transport An explicit connect() during reconnect backoff opened a new transport but left the scheduled retry armed. When it fired on the healthy connection it disposed the transport and reconnected, dropping requests in flight. This is why owned-subscriptions-old-daemon could still fail after #4858 added the explicit reconnect. attemptConnect now owns the rule ensureConnected already applied; the new unit test fails on the old client. * test(e2e): keep the completed-block, font-readiness, and live-page guards inside journeys - agent-stream-ui: the auto-scroll journey anchors on the completed intro block by content and requires its Markdown root to stay the same mounted node while later blocks stream (#1989 guard, reshaped off the tail block). - changes-pane: the code-typography journey holds font loads and requires no diff geometry before they resolve, then applies the font change. - agent-timeline-pagination: the live-output page load runs against a thirty-minute mock stream and gates the release on the running turn, so the status assertion no longer races a ten-second turn. * test(desktop): install the Electron binary once before the unit suite Electron 44 downloads its binary on the first Node require("electron") instead of at npm ci. Vitest workers importing electron-log/main raced that download and extraction (File exists on dist/electron, then "Electron failed to install correctly" in a sibling worker) on both Ubuntu and Windows desktop-tests jobs. Run the package's idempotent install-electron in one process before vitest forks. * ci(desktop-packages): pin actions and smoke packaging-path pull requests Pin checkout, setup-node, and upload-artifact to full commit SHAs. Keep packaging off ordinary PR CI, but run the Linux package smoke on pull requests that touch packages/desktop, nix, or the workflow so a packaging or sandbox regression cannot land on main unchecked. The desktop release workflow already builds and smokes its own artifacts on all platforms.
305 lines
13 KiB
JavaScript
305 lines
13 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { relative as relativePath } from "node:path";
|
|
import test from "node:test";
|
|
|
|
const repoRoot = new URL("../", import.meta.url);
|
|
const ciWorkflowPath = new URL(".github/workflows/ci.yml", repoRoot);
|
|
const dockerWorkflowPath = new URL(".github/workflows/docker.yml", repoRoot);
|
|
const nixWorkflowPath = new URL(".github/workflows/nix.yml", repoRoot);
|
|
const filtersPath = new URL(".github/ci-paths.yml", repoRoot);
|
|
const serverTsconfigPath = new URL("packages/server/tsconfig.server.json", repoRoot);
|
|
const desktopPackagePath = new URL("packages/desktop/package.json", repoRoot);
|
|
|
|
const gatedCiJobs = new Map([
|
|
["format", { name: "format", contract: "format" }],
|
|
["lint", { name: "lint", contract: "quality" }],
|
|
["typecheck", { name: "typecheck", contract: "quality" }],
|
|
["server-tests-ubuntu", { name: "server-tests (ubuntu-latest)", contracts: ["server", "hub"] }],
|
|
["server-tests-windows", { name: "server-tests (windows-latest)", contracts: ["server", "hub"] }],
|
|
["desktop-tests-ubuntu", { name: "desktop-tests (ubuntu-latest)", contract: "desktop" }],
|
|
["desktop-tests-windows", { name: "desktop-tests (windows-latest)", contract: "desktop" }],
|
|
["app-tests", { name: "app-tests", contract: "app" }],
|
|
["sdk-tests", { name: "sdk-tests", contract: "sdk" }],
|
|
["playwright-1", { name: "playwright (shard 1/4)", contract: "browser" }],
|
|
["playwright-2", { name: "playwright (shard 2/4)", contract: "browser" }],
|
|
["playwright-3", { name: "playwright (shard 3/4)", contract: "browser" }],
|
|
["playwright-4", { name: "playwright (shard 4/4)", contract: "browser" }],
|
|
["relay-tests", { name: "relay-tests", contract: "relay" }],
|
|
["cli-tests-1", { name: "cli-tests (shard 1/3)", contract: "cli" }],
|
|
["cli-tests-2", { name: "cli-tests (shard 2/3)", contract: "cli" }],
|
|
["cli-tests-3", { name: "cli-tests (shard 3/3)", contract: "cli" }],
|
|
]);
|
|
|
|
function jobBlocks(source) {
|
|
const jobs = new Map();
|
|
let currentJob;
|
|
|
|
for (const line of source.split("\n")) {
|
|
const jobMatch = /^ ([a-z0-9-]+):\s*$/.exec(line);
|
|
if (jobMatch) {
|
|
currentJob = jobMatch[1];
|
|
jobs.set(currentJob, []);
|
|
continue;
|
|
}
|
|
if (currentJob) jobs.get(currentJob).push(line);
|
|
}
|
|
return jobs;
|
|
}
|
|
|
|
function loadFilters(path) {
|
|
const filters = {};
|
|
let currentFilter;
|
|
|
|
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
const filterMatch = /^([a-z_]+):\s*$/.exec(line);
|
|
if (filterMatch) {
|
|
currentFilter = filterMatch[1];
|
|
filters[currentFilter] = [];
|
|
continue;
|
|
}
|
|
const patternMatch = /^ - "([^"]+)"\s*$/.exec(line);
|
|
if (currentFilter && patternMatch) filters[currentFilter].push(patternMatch[1]);
|
|
}
|
|
return filters;
|
|
}
|
|
|
|
function filesUnder(relativeDirectory, predicate) {
|
|
const directory = new URL(`${relativeDirectory}/`, repoRoot);
|
|
return readdirSync(directory, { recursive: true, withFileTypes: true })
|
|
.filter((entry) => entry.isFile())
|
|
.map((entry) =>
|
|
[relativeDirectory, relativePath(directory.pathname, entry.parentPath), entry.name]
|
|
.filter(Boolean)
|
|
.join("/")
|
|
.replaceAll("\\", "/"),
|
|
)
|
|
.filter(predicate)
|
|
.sort();
|
|
}
|
|
|
|
test("gated checks are statically named jobs with real job-level gating", () => {
|
|
const workflowSource = readFileSync(ciWorkflowPath, "utf8");
|
|
const jobs = jobBlocks(workflowSource);
|
|
const trigger = workflowSource.split("jobs:", 1)[0];
|
|
|
|
assert.match(trigger, /^\s+merge_group:\s*$/m);
|
|
assert.doesNotMatch(workflowSource, /strategy:\s*\n\s+matrix:/);
|
|
assert.doesNotMatch(workflowSource, /RUN_TESTS|Skip unaffected|No .* changes detected/);
|
|
|
|
for (const [jobId, expected] of gatedCiJobs) {
|
|
const job = jobs.get(jobId)?.join("\n");
|
|
assert.ok(job, `missing static job ${jobId}`);
|
|
assert.match(job, new RegExp(`^ name: ${expected.name.replace(/[()]/g, "\\$&")}$`, "m"));
|
|
assert.match(job, /needs\.changes\.outputs\.full != 'false'/);
|
|
for (const contract of expected.contracts ?? [expected.contract]) {
|
|
assert.match(job, new RegExp(`needs\\.changes\\.outputs\\.${contract} != 'false'`));
|
|
}
|
|
}
|
|
});
|
|
|
|
test("change gating allows superseded workflow runs to cancel", () => {
|
|
for (const workflowPath of [ciWorkflowPath, dockerWorkflowPath, nixWorkflowPath]) {
|
|
const source = readFileSync(workflowPath, "utf8");
|
|
assert.doesNotMatch(
|
|
source,
|
|
/\$\{\{\s*always\(\)/,
|
|
"always() keeps jobs alive after concurrency cancellation; use !cancelled() for fail-open gating",
|
|
);
|
|
}
|
|
});
|
|
|
|
test("focused contracts stay inside existing required checks", () => {
|
|
const jobs = jobBlocks(readFileSync(ciWorkflowPath, "utf8"));
|
|
const changes = jobs.get("changes")?.join("\n") ?? "";
|
|
const server = jobs.get("server-tests-ubuntu")?.join("\n") ?? "";
|
|
const desktop = jobs.get("desktop-tests-ubuntu")?.join("\n") ?? "";
|
|
|
|
assert.match(changes, /scripts\/daemon-launch-contract\.test\.mjs/);
|
|
assert.doesNotMatch(changes, /Install dependencies|npm run build/);
|
|
|
|
assert.match(server, /test:hub-cli-contract/);
|
|
assert.match(server, /npm run test --workspace=@getpaseo\/server/);
|
|
assert.ok(!jobs.has("hub-cli-contract"));
|
|
|
|
assert.match(desktop, /test:e2e:renderer/);
|
|
assert.match(desktop, /test:e2e:browser-tabs/);
|
|
assert.match(desktop, /npm run test --workspace=@getpaseo\/desktop/);
|
|
assert.ok(!jobs.has("desktop-browser-bridge"));
|
|
assert.ok(!jobs.has("playwright-desktop"));
|
|
});
|
|
|
|
test("server builds exclude test utilities at every domain depth", () => {
|
|
const tsconfig = JSON.parse(readFileSync(serverTsconfigPath, "utf8"));
|
|
assert.ok(tsconfig.exclude.includes("src/server/**/test-utils/**"));
|
|
assert.ok(!tsconfig.exclude.includes("src/server/test-utils/**"));
|
|
});
|
|
|
|
test("PR routing declares stable behavior ownership", () => {
|
|
const filters = loadFilters(filtersPath);
|
|
assert.deepEqual(filters, {
|
|
routing: [".github/ci-paths.yml"],
|
|
workspace: [
|
|
".mise.toml",
|
|
".tool-versions",
|
|
"package.json",
|
|
"package-lock.json",
|
|
"patches/**",
|
|
"scripts/**",
|
|
"tsconfig.json",
|
|
"tsconfig.base.json",
|
|
"vitest.config.ts",
|
|
],
|
|
ci: [".github/actions/**", ".github/workflows/ci.yml"],
|
|
format: [
|
|
".agents/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}",
|
|
".github/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}",
|
|
"**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}",
|
|
"packages/expo-two-way-audio/**",
|
|
],
|
|
quality: ["**/*.{cjs,js,json,jsx,mjs,ts,tsx}", "packages/expo-two-way-audio/**"],
|
|
hub: ["packages/cli/src/commands/hub/**", "packages/server/src/server/hub/**"],
|
|
server: ["packages/server/**", "packages/app/e2e/support/fixtures/recording.*"],
|
|
desktop: [
|
|
"packages/desktop/**",
|
|
"packages/app/src/desktop/**",
|
|
"packages/server/src/server/browser-tools/**",
|
|
"packages/app/e2e/support/**",
|
|
"packages/app/*config.{cjs,js,ts}",
|
|
"packages/app/package.json",
|
|
],
|
|
app: ["packages/app/**", "packages/expo-two-way-audio/**"],
|
|
sdk: [
|
|
"packages/plugin/**",
|
|
"plugin-examples/**",
|
|
"public-docs/plugins/v0.8/**",
|
|
"packages/client/**",
|
|
"packages/highlight/**",
|
|
"packages/protocol/**",
|
|
],
|
|
browser: [
|
|
"packages/server/src/server/agent/provider-snapshot-manager.ts",
|
|
"packages/server/src/server/session/provider/provider-catalog-session.ts",
|
|
"packages/client/src/compat/normalize-provider-models.ts",
|
|
"packages/protocol/src/client-capabilities.ts",
|
|
"packages/server/src/server/agent/provider-registry.ts",
|
|
"packages/server/src/server/agent/agent-sdk-types.ts",
|
|
"packages/server/src/server/agent/providers/codex-app-server-agent.ts",
|
|
"packages/server/src/server/agent/providers/claude/agent.ts",
|
|
"packages/server/src/server/agent/plugin-provider.ts",
|
|
"packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts",
|
|
"packages/server/src/executable-resolution/**",
|
|
"packages/plugin/src/server/provider.ts",
|
|
"packages/app/src/!(desktop)/**",
|
|
"packages/app/e2e/browser/**",
|
|
"packages/app/e2e/support/**",
|
|
"packages/app/assets/**",
|
|
"packages/app/public/**",
|
|
"packages/app/index.ts",
|
|
"packages/app/*config.{cjs,js,ts}",
|
|
"packages/app/package.json",
|
|
],
|
|
relay: ["packages/relay/**"],
|
|
cli: ["packages/cli/**"],
|
|
});
|
|
});
|
|
|
|
test("cross-package invariants live in the suite that owns them", () => {
|
|
const cliTests = filesUnder("packages/cli", (path) => path.endsWith(".test.ts"));
|
|
assert.ok(cliTests.length > 0);
|
|
for (const path of cliTests) {
|
|
assert.doesNotMatch(
|
|
readFileSync(new URL(path, repoRoot), "utf8"),
|
|
/server\/src\/server\/test-utils/,
|
|
path,
|
|
);
|
|
}
|
|
|
|
const protocolWireCompatibility = new URL(
|
|
"packages/protocol/src/messages.wire-compat.test.ts",
|
|
repoRoot,
|
|
);
|
|
assert.match(readFileSync(protocolWireCompatibility, "utf8"), /wire schema compatibility/);
|
|
});
|
|
|
|
test("browser and desktop tests have exclusive, directory-owned suites", () => {
|
|
const filters = loadFilters(filtersPath);
|
|
const browserSpecs = filesUnder("packages/app/e2e", (path) => path.endsWith(".spec.ts"));
|
|
const desktopSpecs = filesUnder("packages/desktop/e2e", (path) => path.endsWith(".spec.ts"));
|
|
const electronModules = filesUnder("packages/app/src", (path) => /\.electron\.tsx?$/.test(path));
|
|
|
|
assert.ok(browserSpecs.length > 0);
|
|
assert.ok(desktopSpecs.length > 0);
|
|
assert.ok(browserSpecs.every((path) => path.startsWith("packages/app/e2e/browser/")));
|
|
assert.ok(desktopSpecs.every((path) => path.startsWith("packages/desktop/e2e/")));
|
|
assert.ok(electronModules.every((path) => path.startsWith("packages/app/src/desktop/")));
|
|
|
|
const desktopPackage = JSON.parse(readFileSync(desktopPackagePath, "utf8"));
|
|
assert.match(desktopPackage.scripts.test, /--exclude ["']e2e\/\*\*["']/);
|
|
|
|
for (const path of browserSpecs) {
|
|
assert.doesNotMatch(
|
|
readFileSync(new URL(path, repoRoot), "utf8"),
|
|
/paseoDesktop|injectDesktopBridge/,
|
|
);
|
|
}
|
|
for (const path of desktopSpecs) {
|
|
assert.ok(path.startsWith("packages/desktop/e2e/"));
|
|
}
|
|
|
|
const routingSource = readFileSync(filtersPath, "utf8");
|
|
assert.doesNotMatch(routingSource, /desktop_bridge|playwright_desktop|browser-\*|browser-\*\//);
|
|
assert.deepEqual(filters.desktop, [
|
|
"packages/desktop/**",
|
|
"packages/app/src/desktop/**",
|
|
"packages/server/src/server/browser-tools/**",
|
|
"packages/app/e2e/support/**",
|
|
"packages/app/*config.{cjs,js,ts}",
|
|
"packages/app/package.json",
|
|
]);
|
|
assert.deepEqual(filters.browser, [
|
|
"packages/server/src/server/agent/provider-snapshot-manager.ts",
|
|
"packages/server/src/server/session/provider/provider-catalog-session.ts",
|
|
"packages/client/src/compat/normalize-provider-models.ts",
|
|
"packages/protocol/src/client-capabilities.ts",
|
|
"packages/server/src/server/agent/provider-registry.ts",
|
|
"packages/server/src/server/agent/agent-sdk-types.ts",
|
|
"packages/server/src/server/agent/providers/codex-app-server-agent.ts",
|
|
"packages/server/src/server/agent/providers/claude/agent.ts",
|
|
"packages/server/src/server/agent/plugin-provider.ts",
|
|
"packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts",
|
|
"packages/server/src/executable-resolution/**",
|
|
"packages/plugin/src/server/provider.ts",
|
|
"packages/app/src/!(desktop)/**",
|
|
"packages/app/e2e/browser/**",
|
|
"packages/app/e2e/support/**",
|
|
"packages/app/assets/**",
|
|
"packages/app/public/**",
|
|
"packages/app/index.ts",
|
|
"packages/app/*config.{cjs,js,ts}",
|
|
"packages/app/package.json",
|
|
]);
|
|
});
|
|
|
|
test("packaging runs on main without allocating pull-request runners", () => {
|
|
for (const workflowPath of [dockerWorkflowPath, nixWorkflowPath]) {
|
|
const source = readFileSync(workflowPath, "utf8");
|
|
const trigger = source.split("jobs:", 1)[0];
|
|
assert.match(trigger, /push:\s*\n\s+branches: \[main\]/);
|
|
assert.doesNotMatch(trigger, /pull_request/);
|
|
assert.doesNotMatch(source, /dorny\/paths-filter/);
|
|
}
|
|
});
|
|
|
|
test("desktop packaging smokes main pushes and only the pull requests that touch packaging", () => {
|
|
const source = readFileSync(new URL(".github/workflows/desktop-packages.yml", repoRoot), "utf8");
|
|
const trigger = source.split("jobs:", 1)[0];
|
|
assert.match(trigger, /push:\s*\n\s+branches: \[main\]/);
|
|
assert.match(trigger, /pull_request:\s*\n\s+branches: \[main\]\s*\n\s+paths:/);
|
|
assert.match(trigger, /- "packages\/desktop\/\*\*"/);
|
|
assert.doesNotMatch(source, /dorny\/paths-filter/);
|
|
for (const action of ["actions/checkout", "actions/setup-node", "actions/upload-artifact"]) {
|
|
assert.match(source, new RegExp(`${action}@[0-9a-f]{40} # v\\d+\\.\\d+\\.\\d+`));
|
|
}
|
|
});
|