Skip unrelated required checks without losing their names (#2542)
* fix(ci): skip unrelated required checks cleanly Matrix jobs had to start no-op runners to preserve interpolated required-check names. Static named jobs let GitHub report genuine skipped conclusions while keeping those names stable. * fix(ci): gate changes to install patching script * fix(ci): route cross-package test contracts narrowly * fix(ci): keep routed contracts merge-blocking * fix(server): exclude nested test utilities from builds * fix(ci): cover app-owned native and browser domains * refactor(ci): align tests with runtime domains Own browser specs, desktop specs, and Electron-only app code by stable directories so PR routing does not depend on filename conventions. Keep desktop integration coverage inside the required desktop check. * fix(ci): keep routing and desktop suites isolated The routing regression must run before dependency installation, and desktop Vitest must not collect Playwright-owned specs. * fix(ci): isolate desktop e2e runtime * perf(test): isolate Playwright workers * perf(test): parallelize Playwright within specs * fix(test): keep Playwright file scheduling * fix(test): share dynamic Metro fixture * fix(test): preserve own-host restart fixtures * fix(ci): preserve routed contracts after rebase * Stabilize Playwright wheel-scroll assertions
This commit is contained in:
+235
-32
@@ -1,8 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { relative as relativePath } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const workflowPath = new URL("../.github/workflows/ci.yml", import.meta.url);
|
||||
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();
|
||||
@@ -15,43 +42,219 @@ function jobBlocks(source) {
|
||||
jobs.set(currentJob, []);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentJob && (/^ \S/.test(line) || /^ \S/.test(line))) {
|
||||
jobs.get(currentJob).push(line);
|
||||
}
|
||||
if (currentJob) jobs.get(currentJob).push(line);
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
test("matrix jobs expand before change gating", () => {
|
||||
const workflow = readFileSync(workflowPath, "utf8");
|
||||
const gatedMatrixJobs = [...jobBlocks(workflow)]
|
||||
.filter(([, lines]) => {
|
||||
const hasMatrix = lines.some((line) => line.startsWith(" matrix:"));
|
||||
const unsafeJobCondition = lines.some(
|
||||
(line) => line.startsWith(" if:") && line.trim() !== "if: ${{ !cancelled() }}",
|
||||
);
|
||||
return hasMatrix && unsafeJobCondition;
|
||||
})
|
||||
.map(([jobId]) => jobId);
|
||||
function loadFilters(path) {
|
||||
const filters = {};
|
||||
let currentFilter;
|
||||
|
||||
assert.deepEqual(
|
||||
gatedMatrixJobs,
|
||||
[],
|
||||
"change-based job conditions skip a matrix before GitHub can emit its interpolated check names",
|
||||
);
|
||||
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", () => {
|
||||
const workflow = readFileSync(workflowPath, "utf8");
|
||||
const cancellationBlockingJobs = [...jobBlocks(workflow)]
|
||||
.filter(([, lines]) => lines.some((line) => line.trim().startsWith("${{ always()")))
|
||||
.map(([jobId]) => jobId);
|
||||
|
||||
assert.deepEqual(
|
||||
cancellationBlockingJobs,
|
||||
[],
|
||||
"always() keeps jobs alive after concurrency cancellation; use !cancelled() for fail-open gating",
|
||||
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/client/**", "packages/highlight/**", "packages/protocol/**"],
|
||||
browser: [
|
||||
"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/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("non-required Docker and Nix workflows avoid runners with workflow path filters", () => {
|
||||
for (const workflowPath of [dockerWorkflowPath, nixWorkflowPath]) {
|
||||
const source = readFileSync(workflowPath, "utf8");
|
||||
const trigger = source.split("jobs:", 1)[0];
|
||||
assert.match(trigger, /^\s+paths:\s*$/m);
|
||||
assert.doesNotMatch(source, /dorny\/paths-filter/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const repoRoot = join(import.meta.dirname, "..");
|
||||
|
||||
function assertNoDirectWorkerLaunch(label, command) {
|
||||
for (const workerEntrypoint of [
|
||||
"src/server/index.ts",
|
||||
"dist/server/server/index.js",
|
||||
"src/server/daemon-worker.ts",
|
||||
"dist/server/server/daemon-worker.js",
|
||||
]) {
|
||||
assert.ok(
|
||||
!command.includes(workerEntrypoint),
|
||||
`${label} must not launch ${workerEntrypoint} directly: ${command}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoSpawnedWorkerEntrypoint(label, source) {
|
||||
assertNoDirectWorkerLaunch(label, source);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/spawn\([^)]*["'`][^"'`]*\.\.\/index\.ts["'`]/s,
|
||||
`${label} must not spawn ../index.ts directly`,
|
||||
);
|
||||
}
|
||||
|
||||
test("every executable daemon entrypoint enters the supervisor", async () => {
|
||||
const [
|
||||
serverPackageSource,
|
||||
appIsolatedHostDaemon,
|
||||
serverConnectionOfferE2e,
|
||||
desktopRuntimePaths,
|
||||
nixPackage,
|
||||
nixModule,
|
||||
] = await Promise.all([
|
||||
readFile(join(repoRoot, "packages/server/package.json"), "utf8"),
|
||||
readFile(join(repoRoot, "packages/app/e2e/support/helpers/isolated-host-daemon.ts"), "utf8"),
|
||||
readFile(
|
||||
join(repoRoot, "packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts"),
|
||||
"utf8",
|
||||
),
|
||||
readFile(join(repoRoot, "packages/desktop/src/daemon/runtime-paths.ts"), "utf8"),
|
||||
readFile(join(repoRoot, "nix/package.nix"), "utf8"),
|
||||
readFile(join(repoRoot, "nix/module.nix"), "utf8"),
|
||||
]);
|
||||
|
||||
const serverPackage = JSON.parse(serverPackageSource);
|
||||
const startScript = serverPackage.scripts?.start ?? "";
|
||||
const devScript = serverPackage.scripts?.dev ?? "";
|
||||
const devTsxScript = serverPackage.scripts?.["dev:tsx"] ?? "";
|
||||
|
||||
assert.match(startScript, /dist\/scripts\/supervisor-entrypoint\.js/);
|
||||
assertNoDirectWorkerLaunch("server start script", startScript);
|
||||
assert.match(devScript, /scripts\/dev-runner\.ts/);
|
||||
assertNoDirectWorkerLaunch("server dev script", devScript);
|
||||
assert.match(devTsxScript, /scripts\/dev-runner\.ts/);
|
||||
assertNoDirectWorkerLaunch("server dev:tsx script", devTsxScript);
|
||||
|
||||
assert.match(
|
||||
appIsolatedHostDaemon,
|
||||
/spawn\(tsxBin, \["scripts\/supervisor-entrypoint\.ts", "--dev"\]/,
|
||||
);
|
||||
assertNoSpawnedWorkerEntrypoint("app e2e isolated host daemon", appIsolatedHostDaemon);
|
||||
|
||||
assert.match(serverConnectionOfferE2e, /scripts\/supervisor-entrypoint\.ts/);
|
||||
assertNoSpawnedWorkerEntrypoint("server daemon e2e process launch", serverConnectionOfferE2e);
|
||||
|
||||
assert.match(desktopRuntimePaths, /"dist", "scripts", "supervisor-entrypoint\.js"/);
|
||||
assert.match(desktopRuntimePaths, /"scripts", "supervisor-entrypoint\.ts"/);
|
||||
assertNoDirectWorkerLaunch("desktop runtime paths", desktopRuntimePaths);
|
||||
|
||||
assert.match(nixPackage, /dist\/scripts\/supervisor-entrypoint\.js/);
|
||||
assertNoDirectWorkerLaunch("Nix package wrapper", nixPackage);
|
||||
assert.match(nixPackage, /--set PASEO_NODE_ENV production/);
|
||||
assert.doesNotMatch(nixPackage, /--set(-default)?\s+NODE_ENV\b/);
|
||||
assert.doesNotMatch(nixModule, /\bNODE_ENV\b\s*=/);
|
||||
assert.doesNotMatch(nixModule, /\bPASEO_NODE_ENV\b/);
|
||||
});
|
||||
Reference in New Issue
Block a user