Compare commits

...
Author SHA1 Message Date
Jeppe B 12a2d73b32 Replace monolithic E2E matrices with dependency-aware component CI
Generate small reusable component partitions with explicit provider dependencies, GitHub-hosted concurrency, fail-closed evidence, and exact mobile store gates. Include the literal-i18n fix from #206 so validation covers the exact post-merge tree.
2026-07-20 23:52:15 +02:00
35 changed files with 7283 additions and 770 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ concurrency:
jobs:
audit-or-restore:
runs-on: [self-hosted, Linux, X64, default]
runs-on: ubuntu-24.04
timeout-minutes: 10
environment:
name: frontend-production
@@ -50,6 +50,7 @@ jobs:
[[ "$version" == "$manifest_version" ]] || { echo "Tag version $version does not match ios/release.json $manifest_version." >&2; exit 1; }
git show-ref --verify --quiet refs/remotes/origin/master || { echo "origin/master was not included in the full checkout." >&2; exit 1; }
git merge-base --is-ancestor "$source_sha" origin/master || { echo "Tagged commit is not reachable from master." >&2; exit 1; }
[[ "$(git rev-parse origin/master)" == "$source_sha" ]] || { echo "Tagged commit is not the exact current master SHA." >&2; exit 1; }
enabled=false
[[ "$AUTOMATION_ENABLED" == true ]] && enabled=true
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
@@ -60,6 +61,14 @@ jobs:
echo 'No App Store environment or credentials were accessed. Enable only after the signed canary.' >> "$GITHUB_STEP_SUMMARY"
fi
- name: Require complete WebKit mobile tests
if: steps.resolve.outputs.enabled == 'true'
env:
GH_TOKEN: ${{ github.token }}
STORE_SOURCE_SHA: ${{ steps.resolve.outputs.source_sha }}
DEFAULT_BRANCH: master
run: node scripts/mobile/verify-store-test-gate.mjs --platform apple
- name: Download exact TestFlight release manifest
if: steps.resolve.outputs.enabled == 'true'
id: manifest
+14
View File
@@ -82,6 +82,20 @@ jobs:
echo "The verified SHA is no longer current master." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Setup Node.js
if: steps.resolve.outputs.enabled == 'true' && steps.resolve.outputs.current == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Require complete WebKit mobile tests
if: steps.resolve.outputs.enabled == 'true' && steps.resolve.outputs.current == 'true'
env:
GH_TOKEN: ${{ github.token }}
STORE_SOURCE_SHA: ${{ steps.resolve.outputs.source_sha }}
DEFAULT_BRANCH: master
run: node scripts/mobile/verify-store-test-gate.mjs --platform apple
deliver:
name: Sign, upload, process, and distribute
needs: prepare
+22 -18
View File
@@ -49,6 +49,7 @@ on:
permissions:
contents: read
actions: read
concurrency:
group: android-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
@@ -83,31 +84,25 @@ jobs:
id: release-guard
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
current=true
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
latest_sha="$(curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
current=false
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
else
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
fi
latest_sha="$(curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
current=false
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
else
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
echo "Mobile upload commit is the exact current $DEFAULT_BRANCH SHA."
fi
echo "current=$current" >> "$GITHUB_OUTPUT"
@@ -118,6 +113,15 @@ jobs:
node-version: 22
cache: npm
- name: Require complete Chromium mobile tests
if: steps.release-guard.outputs.current == 'true'
env:
GH_TOKEN: ${{ github.token }}
STORE_SOURCE_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
TEST_WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id || '' }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: node scripts/mobile/verify-store-test-gate.mjs --platform android
- name: Setup Java
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'master' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: [self-hosted, Linux, X64, default]
runs-on: ubuntu-24.04
env:
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
@@ -160,7 +160,7 @@ jobs:
deploy-frontend-production:
needs: build-release
if: needs.build-release.outputs.current == 'true'
runs-on: [self-hosted, Linux, X64, default]
runs-on: ubuntu-24.04
timeout-minutes: 90
environment:
name: frontend-production
+84
View File
@@ -0,0 +1,84 @@
name: Dependency Graph Node
on:
workflow_call:
inputs:
node:
description: Stable test-graph node identifier.
required: true
type: string
lane:
description: Contract or Playwright project lane.
required: true
type: string
partitions:
description: JSON array of one-based partition numbers.
required: false
default: "[1]"
type: string
permissions:
contents: read
jobs:
test:
name: ${{ inputs.node }} / ${{ inputs.lane }} / ${{ matrix.partition }}
runs-on: ubuntu-24.04
timeout-minutes: 45
container:
image: mcr.microsoft.com/playwright:v1.58.2-noble
strategy:
fail-fast: false
max-parallel: 100
matrix:
partition: ${{ fromJSON(inputs.partitions) }}
env:
CI: "true"
PLAYWRIGHT_ARTIFACT_NAMESPACE: graph-${{ inputs.node }}-${{ inputs.lane }}-${{ matrix.partition }}
PLAYWRIGHT_REPORTER_MODE: line-html
PLAYWRIGHT_VIDEO_MODE: off
PLAYWRIGHT_WORKERS: 3
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Run dependency graph node
run: >-
node scripts/test-graph/run-node-tests.mjs
--node "${{ inputs.node }}"
--lane "${{ inputs.lane }}"
--partition "${{ matrix.partition }}"
--partitions "${{ strategy.job-total }}"
- name: Upload dependency graph result
if: always()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: test-graph-result-${{ inputs.node }}-${{ inputs.lane }}-${{ matrix.partition }}-${{ github.run_id }}-${{ github.run_attempt }}
path: output/test-graph-results
if-no-files-found: warn
retention-days: 3
- name: Upload failure diagnostics
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: test-graph-${{ inputs.node }}-${{ inputs.lane }}-${{ matrix.partition }}-${{ github.run_id }}-${{ github.run_attempt }}
path: |
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
if-no-files-found: ignore
retention-days: 3
+3373 -654
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -124,9 +124,9 @@ environment, and configured protection rules are evaluated before its secrets
are released.
The existing live-test, Release Manager, and server-version secrets used by
`release.yml` must remain configured. The self-hosted deployment job installs
`lftp` job-locally when needed and installs Playwright Chromium. Its runner
still needs Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and `sha256sum`.
`release.yml` must remain configured. The GitHub-hosted deployment job installs
`lftp` job-locally when needed and installs Playwright Chromium. The workflow
also uses Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and `sha256sum`.
The cPanel account host needs `/bin/sh`, `flock`, `unzip`, `jq`, and
`sha256sum` for the account-scoped activator.
+75
View File
@@ -0,0 +1,75 @@
# Dependency-aware frontend CI
The `Automated Tests` workflow is generated from a repository-owned dependency graph. It replaces the former
`E2E-PR`, `E2E-targeted`, and `E2E-full` matrices with small component-and-lane jobs that can start concurrently
as soon as their actual prerequisites pass.
## Source of truth
- `scripts/test-graph/component-node-catalog.mjs` declares stable component, composable, feature, service, store,
middleware, router, runtime, and view boundaries. It also records test ownership, supported lanes, and runtime
dependencies that cannot be inferred from imports.
- `scripts/test-graph/import-resolver.mjs` scans JavaScript, TypeScript, and Vue imports. The graph builder combines
those inferred edges with the explicit catalog edges and collapses a real strongly connected component into one
atomic execution node if a cycle is ever introduced.
- `scripts/test-graph/test-inventory.mjs` proves that each active unit, component, and E2E spec has exactly one owner
and records the exact expected files and role coverage for every runnable lane. The runner lists each browser file
to capture its exact test count before execution.
- `.github/workflows/tests.yml` is generated by `scripts/test-graph/automated-tests-workflow.mjs`. Do not hand-edit
its component jobs.
Run these checks after changing the graph or test ownership:
```bash
npm run test:graph:validate
npm run test:graph:generate
npm run test:graph:check
```
## Selection rules
Pull requests map changed source files to owning nodes. The plan selects each changed owner, every transitive
consumer that could be affected, and every prerequisite needed to test those consumers. A test-only change selects
its owner and prerequisites but deliberately does not fan out to consumers. An unknown runtime, dependency,
configuration, fixture, or graph change fails closed to the full graph.
Manual targeted runs accept stable node IDs through `target_nodes`. The compatibility `target_components` and
`target_specs` inputs resolve into the same graph. Explicit node and spec targets include both reverse dependents
and prerequisites. `target_projects` narrows browser lanes, while their required contract jobs remain selected. An
unmappable grep request runs the full graph rather than claiming unsafe pruning.
Default-branch pushes, schedules, and `targeted-then-full` dispatches select every runnable contract and all nine
browser/device lanes: Chromium, WebKit, and Firefox on mobile, desktop, and tablet.
## Execution and failure propagation
Each static component/lane caller invokes `.github/workflows/test-graph-node.yml`. Every test spec is an independent
matrix child. The matrix has `fail-fast: false`, `max-parallel: 100`, and three Playwright workers per runner. Jobs
list their direct component dependencies through `needs`; browser and component-test lanes also depend on the build.
A provider failure therefore prevents only its selected consumers from allocating runners. Independent branches
continue.
All Linux jobs are pinned to the GitHub-hosted `ubuntu-24.04` label; Apple build and signing jobs use GitHub-hosted
macOS labels. No repository variable can redirect the dependency graph to a self-hosted machine. Repository and
organization concurrency quotas still determine how many jobs GitHub can start simultaneously. A newer run for the
same event type and pull request or ref cancels its superseded run so hosted capacity goes to the exact commit that
can still merge or release. Push, schedule, and manual runs use separate groups so they cannot cancel the exact
default-branch push proof required by store delivery.
Each partition writes evidence containing its exact listed, green, and intentionally skipped test outcomes, roles, and
run attempt; unexpected outcomes fail the partition. Artifact names include the node, lane, partition, run ID, and
attempt so reruns cannot collide.
Aggregation uses the newest evidence for each partition while retaining successful evidence from earlier attempts.
`Required CI` fails closed if the plan, quality matrix, build, any selected caller, or any evidence/count check is
failed, cancelled, blocked, or missing. Its name remains stable for branch protection.
## Mobile store release gates
The aggregate result artifact is versioned, namespaced by run attempt, and tied to the exact source SHA. Android
release workflows accept only a completed full `master` push whose `Required CI` job succeeded and whose complete
`chromium-mobile` and `ct-chromium-mobile` inventories passed with exact counts and all roles. Apple release workflows
apply the same rules to the complete `webkit-mobile` inventory. Missing nodes, duplicate or unexpected results,
partial profiles, and stale graph versions fail the release gate.
The legacy role-job fallback is temporary migration compatibility and is used only if an older test run has no
dependency result artifact. A present but invalid graph artifact never falls back.
+14
View File
@@ -14,6 +14,10 @@ must never publish an Android production artifact.
- Existing `mobile-v*` tags for the Android workflow.
The Android job continues using GitHub environment `mobile-store-production`.
Before any build or upload, it verifies the exact current `master` SHA, the
overall `Required CI` job, and every manifest-expected `chromium-mobile` and
`ct-chromium-mobile` dependency-graph result. Failed, incomplete, cancelled,
missing, or dependency-blocked mobile results prevent Google Play delivery.
Its required secrets are:
- `ANDROID_KEYSTORE_BASE64`
@@ -37,6 +41,16 @@ iOS uses three separate workflows:
storefront candidate, without rebuilding or submission.
- `iOS Credential Health`: weekly identity, access, and expiry preflight.
TestFlight delivery and App Store candidate promotion both require the exact
current `master` SHA, the overall `Required CI` job, and every
manifest-expected `webkit-mobile` dependency-graph result. The verifier reads
schema-version 2 from the latest artifact
`dependency-ci-results-<source-sha>-<run-attempt>` and file
`dependency-ci-results.json`. Legacy unnumbered graph artifacts remain readable.
Legacy full-matrix job names are accepted only when that artifact is absent
during the dependency-graph migration; an invalid or failing graph artifact
never falls back to legacy jobs.
The GitHub environments and variables are documented in
`docs/app-store-release.md`. The repository-level
`APP_STORE_AUTOMATION_ENABLED` variable gates all access to them and must remain
+6
View File
@@ -50,6 +50,12 @@
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
"test:ct": "playwright test --config=playwright.ct.config.ts",
"test:ct:pr": "playwright test --config=playwright.ct.config.ts --project=chromium-desktop",
"test:graph:validate": "node scripts/test-graph/cli.mjs validate",
"test:graph:plan": "node scripts/test-graph/cli.mjs plan",
"test:graph:generate": "node scripts/test-graph/automated-tests-workflow.mjs",
"test:graph:check": "node scripts/test-graph/automated-tests-workflow.mjs --check",
"test:graph:run-node": "node scripts/test-graph/run-node-tests.mjs",
"test:graph:aggregate": "node scripts/test-graph/aggregate-results.mjs",
"release:package": "node scripts/release/package-dist.mjs",
"release:deploy:cpanel": "node scripts/release/deploy-cpanel.mjs",
"release:deploy:cpanel:rollback": "node scripts/release/deploy-cpanel.mjs --rollback",
+6 -4
View File
@@ -16,10 +16,12 @@ const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim();
const configuredVideoMode = (process.env.PLAYWRIGHT_VIDEO_MODE || "retain-on-failure").trim();
const allowedVideoModes = new Set(["off", "on", "retain-on-failure", "on-first-retry"]);
const videoMode = allowedVideoModes.has(configuredVideoMode) ? configuredVideoMode : "retain-on-failure";
const reporter =
reporterMode === "line-html"
? [["line"], ["html", { open: "never", outputFolder: htmlReportOutputFolder }]]
: [["list"], ["html", { open: "never", outputFolder: htmlReportOutputFolder }]];
const jsonOutputFile = (process.env.PLAYWRIGHT_JSON_OUTPUT_FILE || "").trim();
const reporter = [
[reporterMode === "line-html" ? "line" : "list"],
["html", { open: "never", outputFolder: htmlReportOutputFolder }],
...(jsonOutputFile ? [["json", { outputFile: jsonOutputFile }]] : []),
];
function buildProject(name: string, browserName: "chromium" | "firefox" | "webkit", deviceName: keyof typeof devices) {
const { defaultBrowserType: _defaultBrowserType, ...device } = devices[deviceName];
+6 -4
View File
@@ -6,10 +6,12 @@ const projectRoot = fileURLToPath(new URL(".", import.meta.url));
const artifactNamespace = (process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "ct").trim();
const artifactRoot = path.join("output", "playwright", artifactNamespace);
const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim();
const reporter =
reporterMode === "line-html"
? [["line"], ["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }]]
: [["list"], ["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }]];
const jsonOutputFile = (process.env.PLAYWRIGHT_JSON_OUTPUT_FILE || "").trim();
const reporter = [
[reporterMode === "line-html" ? "line" : "list"],
["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }],
...(jsonOutputFile ? [["json", { outputFile: jsonOutputFile }]] : []),
];
const configuredWorkers = Number(process.env.PLAYWRIGHT_WORKERS || 2);
const workers = Number.isFinite(configuredWorkers) && configuredWorkers > 0 ? configuredWorkers : 2;
+341
View File
@@ -0,0 +1,341 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { loadRepositoryTestGraph } from "../test-graph/load-graph.mjs";
import { DEPENDENCY_CI_ARTIFACT_PREFIX, DEPENDENCY_CI_RESULT_FILE } from "../test-graph/result-schema.mjs";
import { evaluatePlatformResult, normalizeStorePlatform } from "../test-graph/verify-platform-result.mjs";
const execFileAsync = promisify(execFile);
const requiredRoles = ["superuser", "admin", "customer", "subuser"];
function normalizePlatform(platform) {
return normalizeStorePlatform(platform);
}
function platformBrowserLabel(platform) {
return normalizePlatform(platform) === "android" ? "Chromium" : "WebKit";
}
function required(value, name) {
const normalized = String(value || "").trim();
if (!normalized) {
throw new Error(`${name} is required.`);
}
return normalized;
}
function assertFullSha(value) {
const normalized = required(value, "source SHA").toLowerCase();
if (!/^[0-9a-f]{40}$/u.test(normalized)) {
throw new Error("source SHA must be a full lowercase commit SHA.");
}
return normalized;
}
function succeeded(job) {
return job?.status === "completed" && job?.conclusion === "success";
}
function requiredCiFailures(jobs) {
const matches = (jobs || []).filter((job) => job?.name === "Required CI");
if (matches.length !== 1) {
return [`Required CI:${matches.length === 0 ? "missing" : "ambiguous"}`];
}
return succeeded(matches[0])
? []
: [`Required CI:${matches[0].status || "unknown"}/${matches[0].conclusion || "none"}`];
}
export function evaluateDependencyManifest(platform, manifest, jobs = [], graph) {
const evaluated = evaluatePlatformResult(platform, manifest, graph);
const failures = [...requiredCiFailures(jobs), ...evaluated.failures];
return {
lane: evaluated.lanes[0],
lanes: evaluated.lanes,
required: evaluated.required,
failures,
passed: failures.length === 0,
source: "manifest",
};
}
export function expectedLegacyStoreGateJobs(platform) {
const browser = platformBrowserLabel(platform);
return requiredRoles.map((role) => `E2E-full-${browser}-mobile-${role}`);
}
export function evaluateLegacyStoreGate(platform, jobs = []) {
const browser = platformBrowserLabel(platform);
const failures = requiredCiFailures(jobs);
const required = [];
for (const role of requiredRoles) {
const prefix = `E2E-full-${browser}-mobile-${role}`;
const matches = jobs.filter((job) => job?.name === prefix || job?.name?.startsWith(`${prefix}-`));
if (matches.length === 0) {
failures.push(`${prefix}:missing`);
continue;
}
const exact = matches.filter((job) => job.name === prefix);
const shards = matches
.map((job) => ({ job, match: job.name.match(new RegExp(`^${prefix}-(\\d+)of(\\d+)$`, "u")) }))
.filter(({ match }) => match);
if (exact.length === 1 && shards.length === 0) {
required.push(prefix);
if (!succeeded(exact[0])) {
failures.push(`${prefix}:${exact[0].status || "unknown"}/${exact[0].conclusion || "none"}`);
}
continue;
}
if (exact.length > 0 || shards.length !== matches.length) {
failures.push(`${prefix}:ambiguous legacy jobs`);
continue;
}
const shardTotals = new Set(shards.map(({ match }) => Number(match[2])));
if (shardTotals.size !== 1) {
failures.push(`${prefix}:inconsistent shard totals`);
continue;
}
const total = [...shardTotals][0];
const byIndex = new Map(shards.map(({ job, match }) => [Number(match[1]), job]));
for (let shard = 1; shard <= total; shard += 1) {
const job = byIndex.get(shard);
const name = `${prefix}-${shard}of${total}`;
required.push(name);
if (!job) {
failures.push(`${name}:missing`);
} else if (!succeeded(job)) {
failures.push(`${name}:${job.status || "unknown"}/${job.conclusion || "none"}`);
}
}
}
return { required, failures, passed: failures.length === 0, source: "legacy" };
}
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value.startsWith("--")) {
throw new Error(`Unexpected argument: ${value}`);
}
const [rawName, inlineValue] = value.slice(2).split("=", 2);
const nextValue = inlineValue ?? argv[index + 1];
if (inlineValue === undefined) {
index += 1;
}
args[rawName] = nextValue;
}
return args;
}
function createGitHubClient({ apiUrl, repository, token, fetchImpl = fetch }) {
const baseUrl = `${required(apiUrl, "GitHub API URL").replace(/\/$/u, "")}/repos/${required(
repository,
"GitHub repository"
)}`;
const headers = {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${required(token, "GitHub token")}`,
"X-GitHub-Api-Version": "2022-11-28",
};
const request = async (requestPath, binary = false) => {
const response = await fetchImpl(`${baseUrl}${requestPath}`, { headers });
if (!response.ok) {
throw new Error(`GitHub API ${response.status} for ${requestPath}: ${await response.text()}`);
}
return binary ? Buffer.from(await response.arrayBuffer()) : response.json();
};
return { request };
}
function validateRun(run, { sourceSha, defaultBranch }) {
if (run?.name !== "Automated Tests") {
throw new Error(`Run ${run?.id || "<unknown>"} is not the Automated Tests workflow.`);
}
if (run.head_sha !== sourceSha) {
throw new Error(`Run ${run.id} tested ${run.head_sha || "<unknown>"}, not ${sourceSha}.`);
}
if (run.event !== "push" || run.head_branch !== defaultBranch) {
throw new Error(`Run ${run.id} is not a push run for ${defaultBranch}.`);
}
if (run.status !== "completed") {
throw new Error(`Run ${run.id} is not complete.`);
}
return run;
}
async function resolveTestRun(client, { runId, sourceSha, defaultBranch }) {
if (runId) {
return validateRun(await client.request(`/actions/runs/${encodeURIComponent(runId)}`), {
sourceSha,
defaultBranch,
});
}
const query = new URLSearchParams({
branch: defaultBranch,
event: "push",
status: "completed",
per_page: "100",
});
const response = await client.request(`/actions/workflows/tests.yml/runs?${query}`);
const run = response.workflow_runs?.find((candidate) => candidate.head_sha === sourceSha);
if (!run) {
throw new Error(`No completed Automated Tests push run was found for ${sourceSha} on ${defaultBranch}.`);
}
return validateRun(run, { sourceSha, defaultBranch });
}
async function readLatestAttemptJobs(client, runId) {
const jobs = [];
for (let page = 1; ; page += 1) {
const query = new URLSearchParams({ filter: "latest", per_page: "100", page: String(page) });
const response = await client.request(`/actions/runs/${encodeURIComponent(runId)}/jobs?${query}`);
const pageJobs = response.jobs || [];
jobs.push(...pageJobs);
if (pageJobs.length < 100) {
return jobs;
}
}
}
async function findManifestArtifact(client, runId, sourceSha) {
const artifacts = [];
for (let page = 1; ; page += 1) {
const query = new URLSearchParams({ per_page: "100", page: String(page) });
const response = await client.request(`/actions/runs/${encodeURIComponent(runId)}/artifacts?${query}`);
const pageArtifacts = (response.artifacts || []).filter(
(artifact) => !artifact.expired && artifact.name?.startsWith(DEPENDENCY_CI_ARTIFACT_PREFIX)
);
artifacts.push(...pageArtifacts);
if ((response.artifacts || []).length < 100) {
break;
}
}
if (artifacts.length === 0) {
return null;
}
const exactName = `${DEPENDENCY_CI_ARTIFACT_PREFIX}${sourceSha}`;
const candidates = artifacts
.map((artifact) => {
if (artifact.name === exactName) return { artifact, attempt: 0 };
const match = artifact.name.match(new RegExp(`^${exactName}-(\\d+)$`, "u"));
return match ? { artifact, attempt: Number(match[1]) } : null;
})
.filter(Boolean)
.sort((left, right) => right.attempt - left.attempt || Number(right.artifact.id) - Number(left.artifact.id));
if (
candidates.length === 0 ||
candidates.filter((candidate) => candidate.attempt === candidates[0].attempt).length > 1
) {
throw new Error(`Automated Tests run ${runId} has ambiguous dependency result artifacts.`);
}
return candidates[0].artifact;
}
async function downloadManifest(client, artifact, extractManifest) {
const zip = await client.request(`/actions/artifacts/${encodeURIComponent(artifact.id)}/zip`, true);
if (extractManifest) {
return extractManifest(zip, artifact);
}
const directory = await mkdtemp(path.join(tmpdir(), "dependency-ci-results-"));
const zipPath = path.join(directory, "results.zip");
try {
await writeFile(zipPath, zip, { mode: 0o600 });
const { stdout } = await execFileAsync("unzip", ["-p", zipPath, DEPENDENCY_CI_RESULT_FILE], {
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
});
return JSON.parse(stdout);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
export async function verifyStoreTestGate({
platform,
sourceSha,
runId,
defaultBranch = "master",
apiUrl = "https://api.github.com",
repository,
token,
fetchImpl,
extractManifest,
manifestPath,
}) {
const normalizedPlatform = normalizePlatform(platform);
const normalizedSha = assertFullSha(sourceSha);
const client = createGitHubClient({ apiUrl, repository, token, fetchImpl });
const run = await resolveTestRun(client, {
runId: String(runId || "").trim(),
sourceSha: normalizedSha,
defaultBranch: required(defaultBranch, "default branch"),
});
const jobs = await readLatestAttemptJobs(client, run.id);
const { graph } = await loadRepositoryTestGraph(process.cwd());
let manifest = null;
let artifact = null;
if (manifestPath) {
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} else {
artifact = await findManifestArtifact(client, run.id, normalizedSha);
if (artifact) {
manifest = await downloadManifest(client, artifact, extractManifest);
}
}
if (manifest && manifest.sourceSha !== normalizedSha) {
throw new Error(`Dependency result manifest is for ${manifest.sourceSha || "<unknown>"}, not ${normalizedSha}.`);
}
const result = manifest
? evaluateDependencyManifest(normalizedPlatform, manifest, jobs, graph)
: evaluateLegacyStoreGate(normalizedPlatform, jobs);
if (!result.passed) {
throw new Error(
`${normalizedPlatform} store test gate failed for Automated Tests run ${run.id}: ${result.failures.join(", ")}`
);
}
return {
...result,
runId: run.id,
sourceSha: normalizedSha,
artifactName: artifact?.name || null,
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const result = await verifyStoreTestGate({
platform: args.platform,
sourceSha: args["source-sha"] || process.env.STORE_SOURCE_SHA,
runId: args["run-id"] || process.env.TEST_WORKFLOW_RUN_ID,
defaultBranch: args["default-branch"] || process.env.DEFAULT_BRANCH || "master",
apiUrl: process.env.GITHUB_API_URL,
repository: process.env.GITHUB_REPOSITORY,
token: process.env.GH_TOKEN,
manifestPath: args.manifest,
});
console.log(
`${args.platform} store gate passed for Automated Tests run ${result.runId} using ${
result.source
}: ${result.required.join(", ")}`
);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
+227
View File
@@ -0,0 +1,227 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GRAPH_VERSION } from "./component-node-catalog.mjs";
import { loadRepositoryTestGraph } from "./load-graph.mjs";
import {
DEPENDENCY_CI_RESULT_FILE,
DEPENDENCY_CI_SCHEMA_VERSION,
validateDependencyCiResults,
} from "./result-schema.mjs";
import { workflowDependencyJobIds, workflowJobId } from "./workflow-generator.mjs";
function normalizeNeeds(rawNeeds) {
const normalized = new Map();
for (const [jobId, value] of Object.entries(rawNeeds || {})) {
normalized.set(jobId, typeof value === "string" ? value : value?.result || value?.conclusion || "missing");
}
return normalized;
}
const mapResult = (result) => {
if (result === "success") return "success";
if (result === "failure") return "failed";
if (result === "cancelled") return "cancelled";
return "missing";
};
function summarizePartitions(partitionResults, expectedPartitions) {
const partitions = new Map();
for (const result of partitionResults) {
if (
!Number.isInteger(result.partition) ||
!Number.isInteger(result.partitions) ||
!Number.isInteger(result.expectedFiles) ||
result.expectedFiles < 1 ||
!Number.isInteger(result.expectedTests) ||
result.expectedTests < 1 ||
!Number.isInteger(result.executedTests) ||
result.executedTests < 0 ||
!Number.isInteger(result.skippedTests) ||
result.skippedTests < 0 ||
!Number.isInteger(result.runAttempt) ||
result.runAttempt < 1 ||
result.partitions !== expectedPartitions ||
result.partition < 1 ||
result.partition > expectedPartitions
) {
return {
complete: false,
expectedFiles: 0,
expectedTests: 0,
executedTests: 0,
skippedTests: 0,
roles: [],
};
}
const existing = partitions.get(result.partition);
if (existing?.runAttempt === result.runAttempt) {
return {
complete: false,
expectedFiles: 0,
expectedTests: 0,
executedTests: 0,
skippedTests: 0,
roles: [],
};
}
if (!existing || result.runAttempt > existing.runAttempt) {
partitions.set(result.partition, result);
}
}
const complete = partitions.size === expectedPartitions;
const entries = [...partitions.values()];
return {
complete,
failed: entries.some((entry) => entry.status !== "success"),
expectedFiles: entries.reduce((total, entry) => total + entry.expectedFiles, 0),
expectedTests: entries.reduce((total, entry) => total + Number(entry.expectedTests || 0), 0),
executedTests: entries.reduce((total, entry) => total + Number(entry.executedTests || 0), 0),
skippedTests: entries.reduce((total, entry) => total + Number(entry.skippedTests || 0), 0),
roles: [...new Set(entries.flatMap((entry) => entry.roles || []))].sort(),
};
}
export function aggregateDependencyCiResults({ graph, plan, needs, partitionResults = [], sourceSha = "unknown" }) {
if (!graph) throw new Error("A resolved dependency graph is required for aggregation.");
const selected = new Map(
(plan.selectedJobs || []).map((job) => [job.jobId || workflowJobId(job.nodeId, job.lane), job])
);
const normalizedNeeds = normalizeNeeds(needs);
const infrastructure = Object.fromEntries(
["plan_e2e", "quality", "build"].map((jobId) => [jobId, mapResult(normalizedNeeds.get(jobId) || "missing")])
);
const results = [];
for (const nodeId of graph.topologicalOrder) {
const node = graph.nodes.get(nodeId);
for (const lane of node.lanes) {
const jobId = workflowJobId(nodeId, lane);
const expected = selected.get(jobId);
let status = "unselected";
let expectedTests = 0;
let executedTests = 0;
let skippedTests = 0;
let roles = [];
if (expected) {
const rawResult = normalizedNeeds.get(jobId) || "missing";
const matchingPartitions = partitionResults.filter((entry) => entry.nodeId === nodeId && entry.lane === lane);
const summary = summarizePartitions(matchingPartitions, Number(expected.partitions || 1));
expectedTests = summary.expectedTests;
executedTests = summary.executedTests;
skippedTests = summary.skippedTests;
roles = summary.roles.length > 0 ? summary.roles : [...(expected.roles || [])];
if (rawResult === "skipped") {
const dependencyResults = workflowDependencyJobIds(graph, nodeId, lane).map(
(dependencyJob) => normalizedNeeds.get(dependencyJob) || "missing"
);
if (lane !== "contract") dependencyResults.push(normalizedNeeds.get("build") || "missing");
status = dependencyResults.some((result) => result !== "success") ? "dependency-blocked" : "missing";
} else if (rawResult === "success") {
const expectedCount = Number(expected.expectedFiles || 0);
const completeInventory =
summary.complete &&
summary.expectedFiles === expectedCount &&
summary.expectedTests > 0 &&
summary.executedTests + summary.skippedTests === summary.expectedTests;
status = !completeInventory ? "missing" : summary.failed ? "failed" : "success";
} else {
status = mapResult(rawResult);
}
}
results.push({ nodeId, lane, status, expectedTests, executedTests, skippedTests, roles });
}
}
const infrastructurePassed = Object.values(infrastructure).every((status) => status === "success");
const requiredCi =
infrastructurePassed && results.every((result) => result.status === "success" || result.status === "unselected");
return validateDependencyCiResults({
schemaVersion: DEPENDENCY_CI_SCHEMA_VERSION,
sourceSha,
profile: plan.profile,
graphVersion: GRAPH_VERSION,
requiredCi,
infrastructure,
results,
});
}
function readArgs(argv) {
const parsed = { verify: false };
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (value === "--verify") {
parsed.verify = true;
continue;
}
if (!value.startsWith("--")) throw new Error(`Unexpected argument: ${value}.`);
const [name, inline] = value.slice(2).split("=", 2);
parsed[name] = inline ?? argv[++index];
}
return parsed;
}
async function readPartitionResults(directory) {
const stats = await fs.stat(directory).catch(() => null);
if (!stats) return [];
const results = [];
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
results.push(...(await readPartitionResults(entryPath)));
} else if (entry.name.endsWith(".json")) {
const payload = JSON.parse(await fs.readFile(entryPath, "utf8"));
if (Number.isInteger(payload.partition) && payload.nodeId && payload.lane) results.push(payload);
}
}
return results;
}
export async function main(argv = process.argv.slice(2)) {
const args = readArgs(argv);
if (!args.plan) {
throw new Error("--plan is required.");
}
const repositoryRoot = await fs.realpath(process.cwd());
const { graph } = await loadRepositoryTestGraph(repositoryRoot);
const plan = JSON.parse(await fs.readFile(path.resolve(args.plan), "utf8"));
const needs = args.needs
? JSON.parse(await fs.readFile(path.resolve(args.needs), "utf8"))
: JSON.parse(process.env.NEEDS_JSON || "{}");
const partitionResults = await readPartitionResults(
path.resolve(args["results-directory"] || args["results-dir"] || "output/test-graph-results")
);
const result = aggregateDependencyCiResults({
graph,
plan,
needs,
partitionResults,
sourceSha: args["source-sha"] || process.env.GITHUB_SHA || "unknown",
});
const output = path.resolve(args.output || DEPENDENCY_CI_RESULT_FILE);
await fs.writeFile(output, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(`Wrote dependency CI results to ${output}.`);
if (args.verify && !result.requiredCi) {
const infrastructureFailures = Object.entries(result.infrastructure)
.filter(([, status]) => status !== "success")
.map(([jobId, status]) => `${jobId}:${status}`);
const failures = result.results.filter((entry) => !new Set(["success", "unselected"]).has(entry.status));
throw new Error(
`Dependency CI verification failed: ${[
...infrastructureFailures,
...failures.map((entry) => `${entry.nodeId}/${entry.lane}:${entry.status}`),
].join(", ")}`
);
}
return result;
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
@@ -0,0 +1,296 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadRepositoryTestGraph } from "./load-graph.mjs";
import { generateStaticComponentJobs, workflowJobId } from "./workflow-generator.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const outputPath = path.join(repositoryRoot, ".github/workflows/tests.yml");
const check = process.argv.includes("--check");
function renderHeader() {
return `name: Automated Tests
on:
pull_request:
push:
branches: [main, master, dev]
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
inputs:
mode:
description: Dependency graph profile.
required: true
type: choice
default: full
options: [full, targeted, targeted-then-full]
target_nodes:
description: Comma-separated stable dependency graph node IDs.
required: false
type: string
target_components:
description: Deprecated alias for target_nodes.
required: false
type: string
target_specs:
description: Deprecated comma- or newline-separated Playwright specs.
required: false
type: string
target_projects:
description: JSON array of Playwright projects for targeted runs.
required: false
type: string
default: '["chromium-desktop","chromium-mobile","chromium-tablet","webkit-mobile","webkit-desktop"]'
target_grep:
description: Deprecated grep compatibility input; selects the conservative full graph.
required: false
type: string
permissions:
actions: read
contents: read
concurrency:
group: frontend-tests-\${{ github.workflow }}-\${{ github.event_name }}-\${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
plan_e2e:
name: Plan dependency graph
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
selected_nodes: \${{ steps.plan.outputs.selected_nodes }}
selected_jobs: \${{ steps.plan.outputs.selected_jobs }}
expected_jobs: \${{ steps.plan.outputs.expected_jobs }}
profile: \${{ steps.plan.outputs.profile }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Validate graph and generated workflow
run: |
node scripts/test-graph/cli.mjs validate
node scripts/test-graph/automated-tests-workflow.mjs --check
- name: Resolve dependency-aware selection
id: plan
shell: bash
env:
EVENT_NAME: \${{ github.event_name }}
DEFAULT_BRANCH: \${{ github.event.repository.default_branch }}
REF_NAME: \${{ github.ref_name }}
BEFORE_SHA: \${{ github.event.before || '' }}
BASE_SHA: \${{ github.event.pull_request.base.sha || '' }}
HEAD_SHA: \${{ github.event.pull_request.head.sha || github.sha }}
INPUT_MODE: \${{ inputs.mode || '' }}
TARGET_NODES: \${{ inputs.target_nodes || '' }}
TARGET_COMPONENTS: \${{ inputs.target_components || '' }}
TARGET_SPECS: \${{ inputs.target_specs || '' }}
TARGET_PROJECTS: \${{ inputs.target_projects || '' }}
TARGET_GREP: \${{ inputs.target_grep || '' }}
run: |
set -euo pipefail
profile=pr
if [[ "$EVENT_NAME" == schedule || ("$EVENT_NAME" == push && "$REF_NAME" == "$DEFAULT_BRANCH") ]]; then
profile=full
elif [[ "$EVENT_NAME" == workflow_dispatch ]]; then
if [[ "$INPUT_MODE" == targeted ]]; then profile=targeted; else profile=full; fi
fi
changed_file="$RUNNER_TEMP/dependency-ci-changed-files.txt"
: > "$changed_file"
if [[ "$profile" == pr && "$EVENT_NAME" == pull_request ]]; then
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > "$changed_file"
elif [[ "$profile" == pr && "$EVENT_NAME" == push ]]; then
if [[ "$BEFORE_SHA" =~ ^[0-9a-f]{40}$ && ! "$BEFORE_SHA" =~ ^0{40}$ ]] && git cat-file -e "\${BEFORE_SHA}^{commit}"; then
git diff --name-only "$BEFORE_SHA" "$HEAD_SHA" > "$changed_file"
else
echo "package-lock.json" > "$changed_file"
echo "Push base is unavailable; selecting the full graph." >&2
fi
fi
changed="$(paste -sd, "$changed_file")"
targets="\${TARGET_NODES}\${TARGET_NODES:+,}\${TARGET_COMPONENTS}"
lanes="$(node -e 'const value=process.argv[1]||"[]"; console.log(JSON.parse(value).join(","))' "$TARGET_PROJECTS")"
if [[ -n "$TARGET_GREP" && -z "$targets" && -z "$TARGET_SPECS" ]]; then
profile=full
echo "Deprecated target_grep could not be mapped safely; selecting the full graph." >&2
fi
node scripts/test-graph/cli.mjs plan \
--profile "$profile" \
--changed "$changed" \
--nodes "$targets" \
--specs "$TARGET_SPECS" \
--lanes "$lanes" \
--output "$RUNNER_TEMP/dependency-ci-plan.json" \
--github-output "$GITHUB_OUTPUT"
- name: Upload dependency graph plan
uses: actions/upload-artifact@v4
with:
name: dependency-ci-plan-\${{ github.run_id }}-\${{ github.run_attempt }}
path: \${{ runner.temp }}/dependency-ci-plan.json
if-no-files-found: error
retention-days: 3
quality:
name: Quality / \${{ matrix.check }}
runs-on: ubuntu-24.04
timeout-minutes: 25
strategy:
fail-fast: false
max-parallel: 100
matrix:
check: [format, lint, i18n, encoding, ai-sync]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm ci --legacy-peer-deps
- name: Run quality check
shell: bash
run: |
case "\${{ matrix.check }}" in
format) npm run format:tests:check ;;
lint) npm run lint ;;
i18n) npm run i18n:v2:check ;;
encoding) npm run text:check-encoding ;;
ai-sync) node scripts/sync-ai-workflow.mjs --check ;;
*) exit 1 ;;
esac
build:
name: Build
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm ci --legacy-peer-deps
- run: npm run build
`;
}
function renderRequiredCi(graph) {
const componentJobs = graph.topologicalOrder.flatMap((nodeId) =>
graph.nodes.get(nodeId).lanes.map((lane) => workflowJobId(nodeId, lane))
);
const needs = ["plan_e2e", "quality", "build", ...componentJobs].map((jobId) => ` - ${jobId}`).join("\n");
return `
required_ci:
name: Required CI
if: always()
needs:
${needs}
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- name: Download dependency graph plan
uses: actions/download-artifact@v4
with:
pattern: dependency-ci-plan-\${{ github.run_id }}-*
path: output/test-graph
merge-multiple: true
- name: Download node result manifests
continue-on-error: true
uses: actions/download-artifact@v4
with:
pattern: test-graph-result-*-\${{ github.run_id }}-*
path: output/test-graph-results
- name: Aggregate dependency results
env:
NEEDS_JSON: \${{ toJSON(needs) }}
run: >-
node scripts/test-graph/aggregate-results.mjs
--plan output/test-graph/dependency-ci-plan.json
--results-directory output/test-graph-results
--source-sha "\${{ github.sha }}"
--output dependency-ci-results.json
- name: Upload dependency result manifest
if: always()
uses: actions/upload-artifact@v4
with:
name: dependency-ci-results-\${{ github.sha }}-\${{ github.run_attempt }}
path: dependency-ci-results.json
if-no-files-found: error
retention-days: 14
- name: Enforce required result
env:
NEEDS_JSON: \${{ toJSON(needs) }}
run: >-
node scripts/test-graph/aggregate-results.mjs
--plan output/test-graph/dependency-ci-plan.json
--results-directory output/test-graph-results
--source-sha "\${{ github.sha }}"
--output dependency-ci-results.json
--verify
android_mobile_gate:
name: Mobile E2E gate - Android Chromium mobile
if: >-
always() &&
(github.event_name == 'schedule' ||
(github.event_name == 'push' && github.ref_name == github.event.repository.default_branch) ||
(github.event_name == 'workflow_dispatch' && inputs.mode != 'targeted'))
needs: [required_ci]
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v4
with:
name: dependency-ci-results-\${{ github.sha }}-\${{ github.run_attempt }}
- run: node scripts/test-graph/verify-platform-result.mjs --platform android --manifest dependency-ci-results.json
apple_mobile_gate:
name: Mobile E2E gate - Apple WebKit mobile
if: >-
always() &&
(github.event_name == 'schedule' ||
(github.event_name == 'push' && github.ref_name == github.event.repository.default_branch) ||
(github.event_name == 'workflow_dispatch' && inputs.mode != 'targeted'))
needs: [required_ci]
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v4
with:
name: dependency-ci-results-\${{ github.sha }}-\${{ github.run_attempt }}
- run: node scripts/test-graph/verify-platform-result.mjs --platform apple --manifest dependency-ci-results.json
`;
}
export async function renderAutomatedTestsWorkflow() {
const { graph } = await loadRepositoryTestGraph(repositoryRoot);
const generatedJobs = generateStaticComponentJobs(graph).split("\n").slice(2).join("\n");
return `${renderHeader()}${generatedJobs}${renderRequiredCi(graph)}`;
}
const generated = await renderAutomatedTestsWorkflow();
if (check) {
const current = await fs.readFile(outputPath, "utf8");
if (current !== generated) {
throw new Error(`${path.relative(repositoryRoot, outputPath)} is not synchronized with the dependency graph.`);
}
} else {
await fs.writeFile(outputPath, generated, "utf8");
console.log(`Generated ${path.relative(repositoryRoot, outputPath)}.`);
}
+106
View File
@@ -0,0 +1,106 @@
import fs from "node:fs/promises";
import path from "node:path";
import { GRAPH_VERSION } from "./component-node-catalog.mjs";
import { testOwnersForPath } from "./graph.mjs";
import { loadRepositoryTestGraph } from "./load-graph.mjs";
import { planComponentSelection } from "./planner.mjs";
import { generateStaticComponentJobs, replaceGeneratedWorkflowSection } from "./workflow-generator.mjs";
const command = process.argv[2] || "validate";
const args = process.argv.slice(3);
const repositoryRoot = process.cwd();
const { parsedCatalog, importGraph, graph } = await loadRepositoryTestGraph(repositoryRoot);
const inferredDependencies = importGraph.dependencies;
const valueAfter = (name) => {
const index = args.indexOf(name);
return index === -1 ? "" : args[index + 1] || "";
};
if (command === "validate") {
console.log(
`Dependency graph v${GRAPH_VERSION}: ${graph.nodes.size} execution nodes, ${inferredDependencies.length} inferred imports, ${importGraph.compositionImports.length} composition imports classified, ${graph.sccReport.length} cyclic groups collapsed.`
);
for (const group of graph.sccReport) {
console.log(`SCC ${group.executionNodeId}: ${group.members.join(", ")}`);
}
} else if (command === "plan") {
const profile = valueAfter("--profile") || "pr";
const changedFiles = (valueAfter("--changed") || "")
.split(",")
.map((file) => file.trim())
.filter(Boolean);
const targetNodes = (valueAfter("--nodes") || "")
.split(",")
.map((nodeId) => nodeId.trim())
.filter(Boolean);
const targetSpecs = (valueAfter("--specs") || "")
.split(/[,\n]/u)
.map((file) => file.trim())
.filter(Boolean);
for (const spec of targetSpecs) {
const owners = testOwnersForPath(parsedCatalog, spec);
if (owners.length !== 1) {
throw new Error(`Target spec ${spec} must resolve exactly one component owner.`);
}
targetNodes.push(owners[0]);
}
const targetLanes = (valueAfter("--lanes") || "")
.split(",")
.map((lane) => lane.trim())
.filter(Boolean);
const plan = planComponentSelection({
graph,
parsedCatalog,
profile,
changedFiles,
targetNodes: [...new Set(targetNodes)],
targetLanes,
});
const serialized = `${JSON.stringify(plan, null, 2)}\n`;
const output = valueAfter("--output");
if (output) {
await fs.mkdir(path.dirname(path.resolve(output)), { recursive: true });
await fs.writeFile(path.resolve(output), serialized, "utf8");
} else {
process.stdout.write(serialized);
}
const githubOutput = valueAfter("--github-output") || process.env.GITHUB_OUTPUT;
if (githubOutput) {
await fs.appendFile(
githubOutput,
[
`selected_nodes=${JSON.stringify(plan.selectedNodes)}`,
`selected_jobs=${JSON.stringify(plan.selectedJobs.map((job) => job.jobId))}`,
`profile=${plan.profile}`,
`expected_jobs=${JSON.stringify(plan.selectedJobs)}`,
"",
].join("\n"),
"utf8"
);
}
} else if (command === "generate") {
const generated = generateStaticComponentJobs(graph);
const output = valueAfter("--output");
const check = valueAfter("--check");
const workflow = valueAfter("--workflow") || check;
if (workflow) {
const workflowPath = path.resolve(workflow);
const current = await fs.readFile(workflowPath, "utf8");
const replacement = replaceGeneratedWorkflowSection(current, graph);
if (args.includes("--write")) {
await fs.writeFile(workflowPath, replacement, "utf8");
} else if (args.includes("--check") && current !== replacement) {
throw new Error(`Generated component workflow jobs are stale: ${workflow}.`);
} else if (!args.includes("--check")) {
process.stdout.write(replacement);
}
} else if (output) {
await fs.mkdir(path.dirname(path.resolve(output)), { recursive: true });
await fs.writeFile(path.resolve(output), generated, "utf8");
} else {
process.stdout.write(generated);
}
} else {
throw new Error(`Unknown test graph command: ${command}.`);
}
@@ -0,0 +1,533 @@
export const GRAPH_VERSION = 2;
export const browserLanes = [
"chromium-mobile",
"chromium-desktop",
"chromium-tablet",
"webkit-mobile",
"webkit-desktop",
"webkit-tablet",
"firefox-mobile",
"firefox-desktop",
"firefox-tablet",
];
const contractLanes = ["contract"];
const uiLanes = [...contractLanes, ...browserLanes];
const chromiumCtLanes = ["ct-chromium-desktop", "ct-chromium-mobile"];
const defaultLayers = {
runtime: 10,
store: 10,
service: 10,
middleware: 10,
composable: 20,
feature: 30,
component: 50,
router: 60,
view: 60,
};
const node = (id, label, options) => ({
id,
label,
kind: options.kind,
layer: options.layer ?? defaultLayers[options.kind],
priority: options.priority ?? 50,
sourcePatterns: options.sourcePatterns,
testPatterns: options.testPatterns || [],
lanes: options.lanes ?? contractLanes,
partitions: options.partitions || {},
dependsOn: options.dependsOn || [],
runtimeDependencies: options.runtimeDependencies || [],
importPolicy: options.importPolicy || "semantic-owner",
});
const sourceFileNode = (kind, sourceDirectory, fileName, options = {}) => {
const baseName = fileName.replace(/\.(?:js|ts)$/u, "");
const slug = baseName
.replace(/([a-z0-9])([A-Z])/gu, "$1-$2")
.replace(/[^a-zA-Z0-9]+/gu, "-")
.toLowerCase();
const testPatterns = options.testPatterns || [];
return node(`${kind}-${slug}`, options.label || baseName, {
kind,
priority: 150,
sourcePatterns: [`src/${sourceDirectory}/${fileName}`],
testPatterns,
lanes: testPatterns.length > 0 ? contractLanes : [],
runtimeDependencies: options.runtimeDependencies || [],
});
};
const serviceTestPatterns = {
accountDeletion: ["tests/unit/account-deletion.spec.js"],
apiHealth: ["tests/unit/api-health.spec.js"],
attachmentDownloadLinks: ["tests/unit/attachment-download-links.spec.js"],
buefyDatepicker: ["tests/unit/buefy-datepicker.spec.js"],
dateOnly: ["tests/unit/date-only.spec.js"],
economicTransferQueue: ["tests/unit/economic-transfer-queue.spec.js"],
edgeGateways: ["tests/unit/edge-gateway-service.spec.js"],
frontendMaintenance: ["tests/unit/frontend-maintenance.spec.js"],
installAxiosRequestQueue: ["tests/unit/axios-request-queue.spec.js", "tests/unit/request-queue-concurrency.spec.js"],
invoiceMonthSplitWarning: ["tests/unit/invoice-month-split-warning.spec.js"],
orderDateEvents: ["tests/unit/order-date-events.spec.js"],
relativeDateShortcuts: ["tests/unit/relative-date-shortcuts.spec.js"],
releaseBootstrap: ["tests/unit/release-bootstrap.spec.js"],
releaseChannelAvailability: ["tests/unit/release-channel-availability.spec.js"],
releaseTimeline: ["tests/unit/release-timeline.spec.js"],
releaseUpdate: ["tests/unit/release-update.spec.js"],
requestQueue: ["tests/unit/request-queue-progress.spec.js"],
sessionPayload: ["tests/unit/session-payload.spec.js"],
sessionStorage: ["tests/unit/session-storage.spec.js"],
shellyRelayOptions: ["tests/unit/shelly-relay-options.spec.js"],
TableExcelExportService: ["tests/unit/table-excel-export-service.spec.js"],
tableHeaderPreferences: ["tests/unit/table-header-preferences.spec.js"],
};
const serviceFiles = [
"AutoTableExportService.js",
"CSVService.js",
"PasskeyAuthService.js",
"TableExcelExportService.js",
"TwoFactorAuthService.js",
"accountDeletion.js",
"apiHealth.js",
"attachmentDownloadLinks.js",
"attachmentPreview.js",
"buefyDatepicker.js",
"dateOnly.js",
"departmentCustomerPricing.js",
"departmentDailyReportComplaintCategories.js",
"departmentDailyReportComplaintCustomers.js",
"departmentVisibility.js",
"economicCustomerIdentifiers.js",
"economicTransferQueue.js",
"edgeGateways.js",
"errorReportLauncher.js",
"errorReports.js",
"frontendMaintenance.js",
"installAxiosRequestQueue.js",
"invoiceMonthSplitWarning.js",
"limitedBackoffice.js",
"localeFormatting.js",
"moduleUsage.js",
"orderDateEvents.js",
"orderReceiptPrint.js",
"relativeDateShortcuts.js",
"releaseBootstrap.js",
"releaseChannelAvailability.js",
"releaseHeaders.js",
"releaseTimeline.js",
"releaseTrust.js",
"releaseUpdate.js",
"requestErrorTrace.js",
"requestQueue.js",
"selfServeDynamicImage.js",
"sessionPayload.js",
"sessionStorage.js",
"shellyRelayOptions.js",
"subuserPasswordPolicy.js",
"superuserCoolify.js",
"superuserCron.js",
"superuserDepartmentOverview.js",
"superuserReleases.js",
"superuserSecurity.js",
"tableHeaderPreferences.js",
];
const composableTestPatterns = {
departmentOutsideGateCapabilities: ["tests/unit/department-outside-gate-capabilities.spec.js"],
departmentSelfServeEnabled: ["tests/unit/department-self-serve-enabled.spec.js"],
useDraftTransactionCustomer: ["tests/unit/use-draft-transaction-customer.spec.js"],
useOrderMetadataAutosave: ["tests/unit/use-order-metadata-autosave.spec.js"],
useSelfServeLogic: ["tests/unit/use-self-serve-logic.spec.js", "tests/unit/use-self-serve-logic-production.spec.js"],
useWashDepartments: ["tests/unit/use-wash-departments.spec.js"],
useWashFlowState: ["tests/unit/use-wash-flow-state.spec.js", "tests/unit/use-wash-flow-state-production.spec.js"],
useWashProgress: ["tests/unit/use-wash-progress.spec.js"],
useWashSessionActions: [
"tests/unit/use-wash-session-actions.spec.js",
"tests/unit/use-wash-session-actions-production.spec.js",
],
};
const composableFiles = [
"departmentOutsideGateCapabilities.js",
"departmentSelfServeEnabled.js",
"useAppToast.js",
"useDraftTransactionCustomer.js",
"useEconomicQueueJob.js",
"useHoldToTrigger.ts",
"useOrderMetadataAutosave.js",
"usePosAddCustomerForm.js",
"useSelfServeLogic.js",
"useStoredSessionGuestRedirect.js",
"useStripeReaderAvailability.js",
"useWashDepartments.js",
"useWashFlowState.js",
"useWashProgress.js",
"useWashSessionActions.js",
];
/**
* Semantic test nodes, not a raw directory inventory. Import inference augments
* these explicit boundaries, while runtimeDependencies records relationships
* hidden behind router registration, global Vue state, or runtime injection.
*/
export const componentNodeCatalog = [
node("runtime-assets", "Static runtime assets", {
kind: "runtime",
layer: 10,
priority: 100,
sourcePatterns: ["src/assets/**", "src/themes/**", "public/**"],
lanes: [],
}),
node("runtime-foundation", "Runtime foundation", {
kind: "runtime",
layer: 60,
priority: 10,
sourcePatterns: ["src/App.vue", "src/ThemeConfig.vue", "src/main.js", "src/releaseBootstrap.js", "index.html"],
testPatterns: ["tests/unit/app-*.spec.js", "tests/e2e/release-bootstrap.spec.js"],
lanes: contractLanes,
runtimeDependencies: ["component-release"],
}),
node("runtime-config", "Runtime configuration primitives", {
kind: "runtime",
layer: 10,
priority: 100,
sourcePatterns: ["src/config.js", "src/constants/**"],
lanes: [],
}),
node("runtime-router", "Router and route contracts", {
kind: "router",
layer: 60,
priority: 100,
sourcePatterns: ["src/router.js"],
testPatterns: ["tests/unit/router-*.spec.js", "tests/e2e/navigation.smoke.spec.js"],
lanes: uiLanes,
runtimeDependencies: ["runtime-middleware"],
}),
node("runtime-middleware", "Route middleware", {
kind: "middleware",
sourcePatterns: ["src/middleware/**"],
testPatterns: ["tests/unit/requires-permission.spec.js", "tests/unit/restricted-page-wrapper.spec.js"],
runtimeDependencies: ["runtime-store"],
}),
node("runtime-store", "Shared application store", {
kind: "store",
sourcePatterns: ["src/store/**"],
lanes: [],
}),
node("runtime-i18n", "Runtime internationalization", {
kind: "runtime",
sourcePatterns: ["src/i18n/**", "src/components/i18n/**"],
testPatterns: ["tests/unit/i18n-*.spec.js", "tests/e2e/i18n*.spec.*"],
lanes: uiLanes,
}),
...serviceFiles.map((fileName) =>
sourceFileNode("service", "services", fileName, {
testPatterns: serviceTestPatterns[fileName.replace(/\.js$/u, "")] || [],
})
),
...composableFiles.map((fileName) =>
sourceFileNode("composable", "composables", fileName, {
testPatterns: composableTestPatterns[fileName.replace(/\.(?:js|ts)$/u, "")] || [],
})
),
node("feature-customer", "Customer rules and pricing features", {
kind: "feature",
priority: 100,
sourcePatterns: ["src/features/customer/**"],
testPatterns: ["tests/unit/customer-*.spec.js", "tests/e2e/pos-customer-rules.spec.js"],
lanes: contractLanes,
runtimeDependencies: ["runtime-store"],
}),
node("feature-edge-gateways", "Edge gateway features", {
kind: "feature",
priority: 100,
sourcePatterns: ["src/features/edgeGateways/**"],
testPatterns: ["tests/unit/edge-gateway-*.spec.js", "tests/e2e/edge-gateways*.spec.js"],
lanes: contractLanes,
runtimeDependencies: ["runtime-store"],
}),
node("feature-shared", "Shared feature modules", {
kind: "feature",
priority: 5,
sourcePatterns: ["src/features/**"],
lanes: [],
runtimeDependencies: ["runtime-store"],
}),
node("component-action-settings-wheel", "Action settings wheel", {
kind: "component",
layer: 40,
priority: 200,
sourcePatterns: [
"src/components/displays/buttons/ActionSettingsWheelButton.vue",
"src/components/displays/buttons/ActionSettingsWheelItem.vue",
"src/components/displays/buttons/ActionSettingsWheelItemLabel.vue",
"src/components/displays/buttons/ActionSettingsWheelSelectItem.vue",
"src/components/displays/buttons/ActionSettingsWheelToggleItem.vue",
],
testPatterns: ["tests/unit/action-settings-wheel-button.spec.js", "tests/unit/i18n-action-settings-wheel.spec.js"],
lanes: contractLanes,
runtimeDependencies: ["runtime-i18n", "runtime-store"],
importPolicy: "exact-standalone",
}),
node("component-date-period-selector", "Date period selector", {
kind: "component",
layer: 40,
priority: 200,
sourcePatterns: ["src/components/displays/buttons/DatePeriodSelector.vue"],
testPatterns: ["tests/unit/date-period-selector.spec.js", "tests/ct/date-period-selector.ct.spec.ts"],
lanes: [...contractLanes, ...chromiumCtLanes],
runtimeDependencies: ["runtime-i18n", "runtime-store"],
importPolicy: "exact-standalone",
}),
node("component-pagination", "Pagination and table primitives", {
kind: "component",
layer: 40,
priority: 100,
sourcePatterns: ["src/components/displays/pagination/**", "src/components/pagination/**"],
testPatterns: ["tests/unit/*pagination*.spec.js", "tests/unit/table-*.spec.js"],
lanes: contractLanes,
runtimeDependencies: ["runtime-store", "component-date-period-selector"],
}),
node("component-session", "Session and identity components", {
kind: "component",
layer: 40,
priority: 100,
sourcePatterns: ["src/components/session/**"],
testPatterns: ["tests/unit/session-*.spec.js", "tests/e2e/session-*.spec.ts"],
lanes: uiLanes,
runtimeDependencies: ["runtime-store"],
}),
node("component-request", "Request and connectivity components", {
kind: "component",
layer: 40,
priority: 100,
sourcePatterns: ["src/components/request/**"],
testPatterns: ["tests/unit/request-*.spec.js", "tests/unit/connectivity-*.spec.js"],
lanes: contractLanes,
runtimeDependencies: ["runtime-store"],
}),
node("component-navigation", "Navigation and viewport components", {
kind: "component",
priority: 100,
sourcePatterns: [
"src/components/menus/**",
"src/components/models/navigation/**",
"src/components/page/**",
"src/components/viewport/**",
],
testPatterns: ["tests/unit/navigation-*.spec.js", "tests/unit/header-*.spec.js"],
lanes: contractLanes,
runtimeDependencies: ["runtime-router", "component-session"],
}),
node("component-pos", "Point of sale components", {
kind: "component",
priority: 120,
sourcePatterns: [
"src/components/shop/**",
"src/components/models/pos/**",
"src/components/displays/department/pos/**",
],
testPatterns: [
"tests/unit/pos-*.spec.js",
"tests/unit/orders-*.spec.js",
"tests/e2e/pos*.spec.*",
"tests/e2e/admin-pos-*.spec.ts",
],
lanes: uiLanes,
runtimeDependencies: ["component-action-settings-wheel", "component-pagination", "feature-customer"],
}),
node("component-release", "Release management components", {
kind: "component",
priority: 100,
sourcePatterns: ["src/components/release/**"],
testPatterns: ["tests/unit/release-*.spec.js", "tests/e2e/release-*.spec.*"],
lanes: uiLanes,
runtimeDependencies: ["component-action-settings-wheel", "runtime-store"],
}),
node("component-self-serve", "Self-serve components", {
kind: "component",
priority: 110,
sourcePatterns: ["src/components/displays/selfServe/**"],
testPatterns: ["tests/unit/self-serve-*.spec.js", "tests/e2e/self-serve-*.spec.js"],
lanes: uiLanes,
runtimeDependencies: ["runtime-store", "component-request"],
}),
node("component-edge-gateway", "Edge gateway components", {
kind: "component",
priority: 110,
sourcePatterns: ["src/components/displays/edgeGateway/**"],
testPatterns: ["tests/e2e/edge-gateways*.spec.js"],
lanes: browserLanes,
runtimeDependencies: ["feature-edge-gateways", "component-request"],
}),
node("component-department", "Department components", {
kind: "component",
priority: 80,
sourcePatterns: ["src/components/displays/department/**", "src/components/forms/department/**"],
testPatterns: ["tests/unit/department-*.spec.js"],
lanes: contractLanes,
runtimeDependencies: [
"component-action-settings-wheel",
"component-date-period-selector",
"component-pagination",
"runtime-store",
],
}),
node("component-superuser", "Superuser components", {
kind: "component",
priority: 80,
sourcePatterns: ["src/components/displays/superuser/**", "src/components/forms/superUser/**"],
testPatterns: ["tests/unit/superuser-*.spec.js"],
lanes: contractLanes,
runtimeDependencies: ["component-action-settings-wheel", "component-pagination", "runtime-store"],
}),
node("component-user", "User and subuser components", {
kind: "component",
priority: 80,
sourcePatterns: ["src/components/displays/user/**", "src/components/forms/user/**"],
testPatterns: ["tests/unit/user-*.spec.js", "tests/unit/subuser-*.spec.js"],
lanes: contractLanes,
runtimeDependencies: ["component-action-settings-wheel", "component-pagination", "runtime-store"],
}),
node("component-forms", "Shared forms and selectors", {
kind: "component",
layer: 40,
priority: 40,
sourcePatterns: ["src/components/forms/**", "src/components/displays/selectors/**", "src/components/search/**"],
lanes: [],
runtimeDependencies: ["runtime-store"],
}),
node("component-shared", "Other shared components", {
kind: "component",
layer: 40,
priority: 1,
sourcePatterns: ["src/components/**"],
testPatterns: ["tests/unit/**"],
lanes: contractLanes,
runtimeDependencies: ["runtime-store"],
}),
node("view-auth", "Authentication views", {
kind: "view",
priority: 100,
sourcePatterns: ["src/views/auth/**", "src/views/pages/auth/**"],
testPatterns: ["tests/e2e/auth*.spec.*", "tests/e2e/passkeyAuth.spec.ts", "tests/e2e/twoFactorAuth.spec.ts"],
lanes: browserLanes,
runtimeDependencies: ["component-session", "component-forms", "runtime-router"],
}),
node("view-backoffice", "Limited backoffice views", {
kind: "view",
priority: 100,
sourcePatterns: ["src/views/backoffice/**"],
testPatterns: ["tests/e2e/limited-backoffice.spec.ts"],
lanes: browserLanes,
runtimeDependencies: ["component-department", "component-navigation", "runtime-router"],
}),
node("view-admin", "Department administrator views", {
kind: "view",
priority: 110,
sourcePatterns: ["src/views/dashboards/departmentDashboard/**"],
testPatterns: ["tests/e2e/admin*.spec.*", "tests/e2e/pos*.spec.*"],
lanes: browserLanes,
runtimeDependencies: [
"component-date-period-selector",
"component-department",
"component-pos",
"component-navigation",
"runtime-router",
],
}),
node("view-superuser", "Superuser views", {
kind: "view",
priority: 100,
sourcePatterns: ["src/views/dashboards/superUserDashboard/**"],
testPatterns: ["tests/e2e/superuser-*.spec.*", "tests/e2e/invoice*.spec.*", "tests/e2e/invoicing-*.spec.*"],
lanes: browserLanes,
runtimeDependencies: [
"component-action-settings-wheel",
"component-date-period-selector",
"component-superuser",
"component-navigation",
"runtime-router",
],
}),
node("view-user", "User and subuser views", {
kind: "view",
priority: 100,
sourcePatterns: ["src/views/dashboards/userDashboard/**"],
testPatterns: ["tests/e2e/user*.spec.*", "tests/e2e/subuser*.spec.*"],
lanes: browserLanes,
runtimeDependencies: [
"component-action-settings-wheel",
"component-user",
"component-navigation",
"runtime-router",
],
}),
node("view-guest", "Guest, booking, and PWA views", {
kind: "view",
priority: 100,
sourcePatterns: ["src/views/guest/**"],
testPatterns: ["tests/e2e/guest-*.spec.*", "tests/e2e/booking-*.spec.*", "tests/e2e/qr-*.spec.*"],
lanes: browserLanes,
runtimeDependencies: ["component-forms", "component-navigation", "runtime-router"],
}),
node("view-shared", "Other views and pages", {
kind: "view",
priority: 1,
sourcePatterns: ["src/views/**"],
testPatterns: ["tests/e2e/**"],
lanes: browserLanes,
runtimeDependencies: ["component-date-period-selector", "component-shared", "runtime-router"],
}),
];
export const compositionImportRules = [
{
importer: "src/router.js",
importedPatterns: ["src/views/**"],
mode: "invert",
reason: "Router lazy registration composes views; views depend on the router contract.",
},
{
importer: "src/main.js",
importedPatterns: ["src/App.vue", "src/components/**", "src/views/**"],
mode: "ignore",
reason: "Application bootstrap composition is covered by build and browser lanes.",
},
{
importer: "src/App.vue",
importedPatterns: ["src/components/**", "src/views/**"],
mode: "ignore",
reason: "The root shell composes children rather than providing their reusable contract.",
},
];
export const failClosedChangePatterns = [
/^src\//u,
/^public\//u,
/^index\.html$/u,
/^package(?:-lock)?\.json$/u,
/^vite\.config\.js$/u,
/^playwright(?:\..+)?\.config\.(?:js|ts)$/u,
/^playwright\.global-(?:setup|teardown)\.mjs$/u,
/^scripts\/test-graph\//u,
/^scripts\/run-playwright-/u,
/^tests\//u,
/^\.github\/workflows\//u,
];
export const forceFullChangePatterns = [
/^tests\/(?:ct|e2e)\/(?:fixtures|support)\//u,
/^tests\/unit\/(?:setup|support)(?:\.|\/)/u,
/^(?:vite|vitest|eslint)\.config\.(?:js|ts|mjs)$/u,
/^playwright(?:\..+)?\.config\.(?:js|ts)$/u,
/^playwright\.global-(?:setup|teardown)\.mjs$/u,
/^package(?:-lock)?\.json$/u,
/^(?:\.dockerignore|\.env|\.prettierrc\.json|Dockerfile(?:\..+)?|Gemfile(?:\.lock)?|build\.gradle|capacitor\.config\.ts|env\.d\.ts|gradle\.properties|gradlew(?:\.bat)?|jsconfig\.json|manifest-checksum\.txt|nginx\..+\.conf|openapi\.yaml|settings\.gradle|tsconfig\.json|twa-manifest\.json|wdio\.conf\.js)$/u,
/^scripts\//u,
/^(?:android|ios|app|fastlane|gradle)\//u,
/^\.github\/workflows\//u,
];
+272
View File
@@ -0,0 +1,272 @@
const normalizePath = (value) =>
String(value || "")
.replace(/\\/gu, "/")
.replace(/^\.\//u, "");
export function globToRegExp(glob) {
let expression = "^";
for (let index = 0; index < glob.length; index += 1) {
const character = glob[index];
const next = glob[index + 1];
if (character === "*" && next === "*") {
expression += ".*";
index += 1;
} else if (character === "*") {
expression += "[^/]*";
} else if (character === "?") {
expression += "[^/]";
} else {
expression += character.replace(/[|\\{}()[\]^$+?.]/gu, "\\$&");
}
}
return new RegExp(`${expression}$`, "u");
}
const matchesAny = (file, patterns) => patterns.some((pattern) => globToRegExp(pattern).test(file));
export function parseCatalog(catalog) {
if (!Array.isArray(catalog) || catalog.length === 0) {
throw new Error("The component node catalog must contain at least one node.");
}
const nodes = new Map();
for (const rawNode of catalog) {
if (!/^[a-z][a-z0-9-]*$/u.test(rawNode.id || "")) {
throw new Error(`Invalid component node id: ${rawNode.id || "<empty>"}.`);
}
if (nodes.has(rawNode.id)) {
throw new Error(`Duplicate component node id: ${rawNode.id}.`);
}
if (!Array.isArray(rawNode.sourcePatterns) || rawNode.sourcePatterns.length === 0) {
throw new Error(`Component node ${rawNode.id} must own at least one source pattern.`);
}
if (!Number.isInteger(rawNode.layer) || rawNode.layer < 0) {
throw new Error(`Component node ${rawNode.id} must have a non-negative integer layer.`);
}
const lanes = [...new Set(rawNode.lanes || ["contract"])];
if (lanes.some((lane) => !/^[a-z][a-z0-9-]*$/u.test(lane))) {
throw new Error(`Component node ${rawNode.id} has invalid lanes.`);
}
nodes.set(rawNode.id, {
...rawNode,
priority: Number(rawNode.priority || 0),
sourcePatterns: [...rawNode.sourcePatterns],
testPatterns: [...(rawNode.testPatterns || [])],
lanes,
partitions: { ...(rawNode.partitions || {}) },
dependsOn: [...new Set([...(rawNode.dependsOn || []), ...(rawNode.runtimeDependencies || [])])],
});
for (const [lane, count] of Object.entries(rawNode.partitions || {})) {
if (!lanes.includes(lane) || !Number.isInteger(count) || count < 1) {
throw new Error(`Component node ${rawNode.id} has invalid partition metadata for ${lane}.`);
}
}
}
for (const node of nodes.values()) {
for (const dependency of node.dependsOn) {
if (!nodes.has(dependency)) {
throw new Error(`Component node ${node.id} depends on unknown node ${dependency}.`);
}
if (dependency === node.id) {
throw new Error(`Component node ${node.id} cannot depend on itself.`);
}
}
}
return { nodes };
}
function ownersForPatterns(parsedCatalog, file, field) {
const normalized = normalizePath(file);
const matches = [...parsedCatalog.nodes.values()].filter((node) => matchesAny(normalized, node[field] || []));
if (matches.length === 0) {
return [];
}
const highestPriority = Math.max(...matches.map((node) => node.priority));
return matches
.filter((node) => node.priority === highestPriority)
.map((node) => node.id)
.sort();
}
export const sourceOwnersForPath = (parsedCatalog, file) => ownersForPatterns(parsedCatalog, file, "sourcePatterns");
export const testOwnersForPath = (parsedCatalog, file) => ownersForPatterns(parsedCatalog, file, "testPatterns");
function stronglyConnectedComponents(dependencies) {
let index = 0;
const indices = new Map();
const lowLinks = new Map();
const stack = [];
const onStack = new Set();
const components = [];
const visit = (nodeId) => {
indices.set(nodeId, index);
lowLinks.set(nodeId, index);
index += 1;
stack.push(nodeId);
onStack.add(nodeId);
for (const dependency of dependencies.get(nodeId)) {
if (!indices.has(dependency)) {
visit(dependency);
lowLinks.set(nodeId, Math.min(lowLinks.get(nodeId), lowLinks.get(dependency)));
} else if (onStack.has(dependency)) {
lowLinks.set(nodeId, Math.min(lowLinks.get(nodeId), indices.get(dependency)));
}
}
if (lowLinks.get(nodeId) === indices.get(nodeId)) {
const component = [];
let member;
do {
member = stack.pop();
onStack.delete(member);
component.push(member);
} while (member !== nodeId);
components.push(component.sort());
}
};
for (const nodeId of [...dependencies.keys()].sort()) {
if (!indices.has(nodeId)) visit(nodeId);
}
return components;
}
function stableHash(value) {
let hash = 2166136261;
for (const character of value) {
hash ^= character.codePointAt(0);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
function collapseDependencyCycles(parsedCatalog, rawDependencies) {
const components = stronglyConnectedComponents(rawDependencies);
const originalToExecutionNode = new Map();
const nodes = new Map();
const sccReport = [];
for (const members of components) {
const executionNodeId = members.length === 1 ? members[0] : `atomic-${members[0]}-${stableHash(members.join("|"))}`;
for (const member of members) originalToExecutionNode.set(member, executionNodeId);
if (members.length === 1) {
nodes.set(executionNodeId, { ...parsedCatalog.nodes.get(members[0]), members });
continue;
}
const memberNodes = members.map((member) => parsedCatalog.nodes.get(member));
const lanes = [...new Set(memberNodes.flatMap((member) => member.lanes))].sort();
const partitions = {};
for (const lane of lanes) {
partitions[lane] = Math.max(...memberNodes.map((member) => member.partitions[lane] || 1));
}
nodes.set(executionNodeId, {
id: executionNodeId,
label: `Atomic group: ${members.map((member) => parsedCatalog.nodes.get(member).label).join(", ")}`,
kind: "atomic",
priority: Math.max(...memberNodes.map((member) => member.priority)),
sourcePatterns: memberNodes.flatMap((member) => member.sourcePatterns),
testPatterns: memberNodes.flatMap((member) => member.testPatterns),
lanes,
partitions,
dependsOn: [],
members,
});
sccReport.push({ executionNodeId, members });
}
const dependencies = new Map([...nodes.keys()].map((nodeId) => [nodeId, new Set()]));
for (const [consumer, prerequisites] of rawDependencies) {
const executionConsumer = originalToExecutionNode.get(consumer);
for (const prerequisite of prerequisites) {
const executionPrerequisite = originalToExecutionNode.get(prerequisite);
if (executionConsumer !== executionPrerequisite) dependencies.get(executionConsumer).add(executionPrerequisite);
}
}
return { nodes, dependencies, originalToExecutionNode, sccReport };
}
export function createDependencyGraph(parsedCatalog, inferredDependencies = [], { collapseCycles = false } = {}) {
const rawDependencies = new Map();
for (const nodeId of parsedCatalog.nodes.keys()) {
rawDependencies.set(nodeId, new Set(parsedCatalog.nodes.get(nodeId).dependsOn));
}
for (const edge of inferredDependencies) {
if (!parsedCatalog.nodes.has(edge.from) || !parsedCatalog.nodes.has(edge.to)) {
throw new Error(`Import dependency references an unknown node: ${edge.from} -> ${edge.to}.`);
}
if (edge.from !== edge.to) rawDependencies.get(edge.from).add(edge.to);
}
const collapsed = collapseCycles
? collapseDependencyCycles(parsedCatalog, rawDependencies)
: {
nodes: parsedCatalog.nodes,
dependencies: rawDependencies,
originalToExecutionNode: new Map([...parsedCatalog.nodes.keys()].map((nodeId) => [nodeId, nodeId])),
sccReport: [],
};
const { nodes, dependencies, originalToExecutionNode, sccReport } = collapsed;
const dependents = new Map([...nodes.keys()].map((nodeId) => [nodeId, new Set()]));
for (const [consumer, prerequisites] of dependencies) {
for (const prerequisite of prerequisites) {
dependents.get(prerequisite).add(consumer);
}
}
const graph = { nodes, dependencies, dependents, originalToExecutionNode, sccReport };
graph.topologicalOrder = topologicalSort(graph);
return graph;
}
export function topologicalSort(graph) {
const remaining = new Map([...graph.dependencies].map(([nodeId, dependencies]) => [nodeId, new Set(dependencies)]));
const ordered = [];
while (remaining.size > 0) {
const ready = [...remaining]
.filter(([, dependencies]) => dependencies.size === 0)
.map(([nodeId]) => nodeId)
.sort();
if (ready.length === 0) {
const cycle = [...remaining].map(([nodeId, dependencies]) => `${nodeId}->${[...dependencies].join(",")}`);
throw new Error(`Component dependency graph contains a cycle: ${cycle.join("; ")}`);
}
for (const nodeId of ready) {
ordered.push(nodeId);
remaining.delete(nodeId);
for (const dependencies of remaining.values()) {
dependencies.delete(nodeId);
}
}
}
return ordered;
}
function closure(graph, seedNodeIds, edgeMap) {
const selected = new Set();
const pending = [...seedNodeIds];
while (pending.length > 0) {
const nodeId = pending.pop();
if (selected.has(nodeId)) {
continue;
}
if (!graph.nodes.has(nodeId)) {
throw new Error(`Unknown component node: ${nodeId}.`);
}
selected.add(nodeId);
pending.push(...edgeMap.get(nodeId));
}
return selected;
}
export const prerequisiteClosure = (graph, seedNodeIds) => closure(graph, seedNodeIds, graph.dependencies);
export const dependentClosure = (graph, seedNodeIds) => closure(graph, seedNodeIds, graph.dependents);
export const sortNodeIds = (graph, nodeIds) => {
const selected = new Set(nodeIds);
return graph.topologicalOrder.filter((nodeId) => selected.has(nodeId));
};
export const normalizeGraphPath = normalizePath;
+172
View File
@@ -0,0 +1,172 @@
import fs from "node:fs/promises";
import path from "node:path";
import { globToRegExp, normalizeGraphPath, sourceOwnersForPath } from "./graph.mjs";
const sourceExtensions = [".vue", ".js", ".ts", ".mjs", ".json"];
export function parseImportSpecifiers(source) {
const specifiers = new Set();
const patterns = [
/\bimport(?:\s+[^"'()]+?\s+from\s+)?["']([^"']+)["']/gu,
/\bexport\s+[^"']*?\s+from\s+["']([^"']+)["']/gu,
/\bimport\(\s*["']([^"']+)["']\s*\)/gu,
/\brequire\(\s*["']([^"']+)["']\s*\)/gu,
];
for (const pattern of patterns) {
for (const match of source.matchAll(pattern)) {
specifiers.add(match[1]);
}
}
return [...specifiers];
}
async function existingFile(candidate) {
const candidates = [
candidate,
...sourceExtensions.map((extension) => `${candidate}${extension}`),
...sourceExtensions.map((extension) => path.join(candidate, `index${extension}`)),
];
for (const file of candidates) {
const stats = await fs.stat(file).catch(() => null);
if (stats?.isFile()) {
return file;
}
}
return null;
}
export async function resolveImportSpecifier({ repositoryRoot, importer, specifier }) {
let candidate;
if (specifier.startsWith("@/")) {
candidate = path.join(repositoryRoot, "src", specifier.slice(2));
} else if (specifier.startsWith("/src/")) {
candidate = path.join(repositoryRoot, specifier.slice(1));
} else if (specifier.startsWith("./") || specifier.startsWith("../")) {
candidate = path.resolve(path.dirname(importer), specifier);
} else {
return null;
}
return existingFile(candidate);
}
async function collectFiles(directory) {
const entries = await fs.readdir(directory, { withFileTypes: true });
const files = [];
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectFiles(entryPath)));
} else if (sourceExtensions.includes(path.extname(entry.name))) {
files.push(entryPath);
}
}
return files;
}
export function validateCompositionImportRules(parsedCatalog, rules) {
for (const rule of rules) {
if (!new Set(["ignore", "invert"]).has(rule.mode)) {
throw new Error(`Composition import rule for ${rule.importer} has invalid mode ${rule.mode}.`);
}
if (sourceOwnersForPath(parsedCatalog, rule.importer).length === 0) {
throw new Error(`Composition import rule importer is not owned: ${rule.importer}.`);
}
if (!Array.isArray(rule.importedPatterns) || rule.importedPatterns.length === 0 || !rule.reason) {
throw new Error(`Composition import rule for ${rule.importer} must include targets and a reason.`);
}
}
}
/**
* Lower-layer files occasionally import a higher-layer implementation as a
* composition hook. In those cases the effective provider-to-consumer edge is
* inverted. Every other first-party import remains a real dependency edge;
* cycles caused by mutually dependent boundaries are collapsed atomically by
* the graph builder instead of being hidden.
*/
export function classifySemanticImport(parsedCatalog, from, to) {
const fromNode = parsedCatalog.nodes.get(from);
const toNode = parsedCatalog.nodes.get(to);
if (fromNode.layer < toNode.layer) {
return {
mode: "invert",
reason:
"A lower-layer provider importing a higher-layer implementation is composition; the consumer edge is inverted.",
};
}
return null;
}
export async function inferImportGraph(repositoryRoot, parsedCatalog, compositionRules = []) {
validateCompositionImportRules(parsedCatalog, compositionRules);
const sourceRoot = path.join(repositoryRoot, "src");
const files = await collectFiles(sourceRoot);
const aggregated = new Map();
const compositionImports = [];
for (const importer of files) {
const relativeImporter = normalizeGraphPath(path.relative(repositoryRoot, importer));
const importerOwners = sourceOwnersForPath(parsedCatalog, relativeImporter);
if (importerOwners.length === 0) {
continue;
}
const source = await fs.readFile(importer, "utf8");
for (const specifier of parseImportSpecifiers(source)) {
const importedFile = await resolveImportSpecifier({ repositoryRoot, importer, specifier });
if (!importedFile) {
continue;
}
const relativeImported = normalizeGraphPath(path.relative(repositoryRoot, importedFile));
const importedOwners = sourceOwnersForPath(parsedCatalog, relativeImported);
const compositionRule = compositionRules.find(
(rule) =>
rule.importer === relativeImporter &&
rule.importedPatterns.some((pattern) => globToRegExp(pattern).test(relativeImported))
);
for (const from of importerOwners) {
for (const to of importedOwners) {
if (from === to) {
continue;
}
const semanticRule = classifySemanticImport(parsedCatalog, from, to);
const effectiveRule = compositionRule || semanticRule;
if (effectiveRule?.mode === "ignore") {
compositionImports.push({
from,
to,
importer: relativeImporter,
imported: relativeImported,
...effectiveRule,
});
continue;
}
const edgeFrom = effectiveRule?.mode === "invert" ? to : from;
const edgeTo = effectiveRule?.mode === "invert" ? from : to;
const key = `${edgeFrom}\0${edgeTo}`;
const edge = aggregated.get(key) || { from: edgeFrom, to: edgeTo, imports: [] };
edge.imports.push({ importer: relativeImporter, imported: relativeImported, specifier });
aggregated.set(key, edge);
if (effectiveRule) {
compositionImports.push({
from: edgeFrom,
to: edgeTo,
importer: relativeImporter,
imported: relativeImported,
...effectiveRule,
});
}
}
}
}
}
return {
dependencies: [...aggregated.values()].sort((left, right) =>
`${left.from}:${left.to}`.localeCompare(`${right.from}:${right.to}`)
),
compositionImports,
};
}
export const inferImportDependencies = async (repositoryRoot, parsedCatalog, compositionRules = []) =>
(await inferImportGraph(repositoryRoot, parsedCatalog, compositionRules)).dependencies;
+12
View File
@@ -0,0 +1,12 @@
import { componentNodeCatalog, compositionImportRules } from "./component-node-catalog.mjs";
import { createDependencyGraph, parseCatalog } from "./graph.mjs";
import { inferImportGraph } from "./import-resolver.mjs";
import { attachTestInventory } from "./test-inventory.mjs";
export async function loadRepositoryTestGraph(repositoryRoot) {
const parsedCatalog = parseCatalog(componentNodeCatalog);
const importGraph = await inferImportGraph(repositoryRoot, parsedCatalog, compositionImportRules);
const graph = createDependencyGraph(parsedCatalog, importGraph.dependencies, { collapseCycles: true });
await attachTestInventory(repositoryRoot, parsedCatalog, graph);
return { parsedCatalog, importGraph, graph };
}
+170
View File
@@ -0,0 +1,170 @@
import { browserLanes, failClosedChangePatterns, forceFullChangePatterns } from "./component-node-catalog.mjs";
import {
dependentClosure,
normalizeGraphPath,
prerequisiteClosure,
sortNodeIds,
sourceOwnersForPath,
testOwnersForPath,
} from "./graph.mjs";
import { workflowJobId } from "./workflow-generator.mjs";
const prLanes = new Set([
"contract",
"ct-chromium-desktop",
"ct-chromium-mobile",
"chromium-desktop",
"chromium-mobile",
]);
function normalizeTargetLanes(targetLanes) {
const normalized = new Set();
for (const lane of targetLanes) {
if (!browserLanes.includes(lane)) {
throw new Error(`Unknown target browser lane: ${lane}.`);
}
normalized.add(lane);
if (lane === "chromium-desktop" || lane === "chromium-mobile") {
normalized.add(`ct-${lane}`);
}
}
return [...normalized];
}
const selectedLanesFor = (node, profile, targetLanes) => {
if (profile === "full") {
return node.lanes;
}
if (profile === "targeted" && targetLanes.length > 0) {
const allowed = new Set(["contract", ...targetLanes]);
return node.lanes.filter((lane) => allowed.has(lane));
}
return node.lanes.filter((lane) => prLanes.has(lane));
};
export function planComponentSelection({
graph,
parsedCatalog,
changedFiles = [],
profile = "pr",
targetNodes = [],
targetLanes = [],
includeDependents = true,
}) {
if (!new Set(["pr", "full", "targeted"]).has(profile)) {
throw new Error(`Unsupported component test profile: ${profile}.`);
}
const emptyTargetedRequest = profile === "targeted" && targetNodes.length === 0 && changedFiles.length === 0;
const effectiveProfile = emptyTargetedRequest ? "full" : profile;
const normalizedTargetLanes = normalizeTargetLanes(targetLanes);
const seeds = new Set(targetNodes);
const sourceSeeds = new Set();
const unknownCriticalFiles = [];
const reasons = {};
const executionNode = (nodeId) => graph.originalToExecutionNode?.get(nodeId) || nodeId;
const requestedTargetNodes = [...seeds];
seeds.clear();
for (const nodeId of requestedTargetNodes) {
if (!parsedCatalog.nodes.has(nodeId) && !graph.nodes.has(nodeId)) {
throw new Error(`Unknown target component node: ${nodeId}.`);
}
const targetNodeId = executionNode(nodeId);
seeds.add(targetNodeId);
sourceSeeds.add(targetNodeId);
reasons[targetNodeId] = [...(reasons[targetNodeId] || []), "explicit-target"];
}
if (effectiveProfile === "full") {
for (const nodeId of graph.nodes.keys()) {
seeds.add(nodeId);
reasons[nodeId] = ["full-profile"];
}
} else {
for (const rawFile of changedFiles) {
const file = normalizeGraphPath(rawFile);
const sharedTestInfrastructure = file.startsWith("tests/") && !/\.spec\.(?:js|ts)$/u.test(file);
if (sharedTestInfrastructure || forceFullChangePatterns.some((pattern) => pattern.test(file))) {
unknownCriticalFiles.push(file);
continue;
}
const sourceOwners = sourceOwnersForPath(parsedCatalog, file);
const testOwners = testOwnersForPath(parsedCatalog, file);
if (sourceOwners.length > 0) {
for (const ownerId of sourceOwners) {
const nodeId = executionNode(ownerId);
seeds.add(nodeId);
sourceSeeds.add(nodeId);
reasons[nodeId] = [...(reasons[nodeId] || []), `source:${file}`];
}
} else if (testOwners.length > 0) {
for (const ownerId of testOwners) {
const nodeId = executionNode(ownerId);
seeds.add(nodeId);
reasons[nodeId] = [...(reasons[nodeId] || []), `test:${file}`];
}
} else if (failClosedChangePatterns.some((pattern) => pattern.test(file))) {
unknownCriticalFiles.push(file);
}
}
}
let selected;
const fullGraph = effectiveProfile === "full" || unknownCriticalFiles.length > 0;
if (fullGraph) {
selected = new Set(graph.nodes.keys());
for (const nodeId of selected) {
reasons[nodeId] = [
...(reasons[nodeId] || []),
unknownCriticalFiles.length ? "fail-closed" : emptyTargetedRequest ? "empty-target-fallback" : "full-profile",
];
}
} else {
selected = new Set(seeds);
if (includeDependents && sourceSeeds.size > 0) {
for (const nodeId of dependentClosure(graph, sourceSeeds)) {
selected.add(nodeId);
if (!seeds.has(nodeId)) {
reasons[nodeId] = [...(reasons[nodeId] || []), "dependent-impact"];
}
}
}
selected = prerequisiteClosure(graph, selected);
for (const nodeId of selected) {
if (!reasons[nodeId]) {
reasons[nodeId] = ["prerequisite"];
}
}
}
const selectedNodes = sortNodeIds(graph, selected);
const selectedJobs = selectedNodes.flatMap((nodeId) => {
const node = graph.nodes.get(nodeId);
return selectedLanesFor(node, effectiveProfile, normalizedTargetLanes).map((lane) => {
const inventory = node.testInventory?.[lane];
if (!inventory || inventory.expectedFiles < 1) {
throw new Error(`${nodeId}/${lane} cannot be selected because it resolves zero owned tests.`);
}
return {
jobId: workflowJobId(nodeId, lane),
nodeId,
lane,
expectedFiles: inventory.expectedFiles,
roles: inventory.roles,
partitions: node.partitions[lane] || 1,
};
});
});
return {
profile: effectiveProfile,
fullGraph,
seedNodes: sortNodeIds(graph, seeds),
selectedNodes,
selectedJobs,
unknownCriticalFiles: unknownCriticalFiles.sort(),
reasons,
};
}
export const defaultBrowserLanes = browserLanes;
+60
View File
@@ -0,0 +1,60 @@
export const DEPENDENCY_CI_ARTIFACT_PREFIX = "dependency-ci-results-";
export const DEPENDENCY_CI_RESULT_FILE = "dependency-ci-results.json";
export const DEPENDENCY_CI_SCHEMA_VERSION = 2;
export const dependencyCiStatuses = ["success", "failed", "dependency-blocked", "cancelled", "missing", "unselected"];
const requiredString = (value, label) => {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${label} must be a non-empty string.`);
}
};
export function validateDependencyCiResults(payload) {
if (!payload || typeof payload !== "object") {
throw new Error("Dependency CI results must be an object.");
}
if (payload.schemaVersion !== DEPENDENCY_CI_SCHEMA_VERSION) {
throw new Error(`Unsupported dependency CI schema version: ${payload.schemaVersion}.`);
}
requiredString(payload.sourceSha, "sourceSha");
requiredString(payload.profile, "profile");
if (!Number.isInteger(payload.graphVersion) || payload.graphVersion < 1) {
throw new Error("graphVersion must be a positive integer.");
}
if (typeof payload.requiredCi !== "boolean") {
throw new Error("requiredCi must be boolean.");
}
if (payload.infrastructure !== undefined) {
if (
!payload.infrastructure ||
typeof payload.infrastructure !== "object" ||
Array.isArray(payload.infrastructure)
) {
throw new Error("infrastructure must be an object.");
}
for (const jobId of ["plan_e2e", "quality", "build"]) {
if (!dependencyCiStatuses.includes(payload.infrastructure[jobId])) {
throw new Error(`infrastructure.${jobId} is invalid.`);
}
}
}
if (!Array.isArray(payload.results)) {
throw new Error("results must be an array.");
}
for (const [index, result] of payload.results.entries()) {
requiredString(result?.nodeId, `results[${index}].nodeId`);
requiredString(result?.lane, `results[${index}].lane`);
if (!dependencyCiStatuses.includes(result?.status)) {
throw new Error(`results[${index}].status is invalid.`);
}
for (const countField of ["expectedTests", "executedTests", "skippedTests"]) {
if (!Number.isInteger(result[countField]) || result[countField] < 0) {
throw new Error(`results[${index}].${countField} must be a non-negative integer.`);
}
}
if (!Array.isArray(result.roles) || result.roles.some((role) => typeof role !== "string" || !role)) {
throw new Error(`results[${index}].roles must be an array of non-empty strings.`);
}
}
return payload;
}
+305
View File
@@ -0,0 +1,305 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { testOwnersForPath } from "./graph.mjs";
import { loadRepositoryTestGraph } from "./load-graph.mjs";
import { classifyTest, parseListedTests } from "../run-playwright-full-slice.mjs";
import { isDefaultE2eFile } from "./test-inventory.mjs";
const execFileAsync = promisify(execFile);
const testExtensions = new Set([".js", ".ts"]);
function readArgs(argv) {
const values = {};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (!argument.startsWith("--")) {
throw new Error(`Unexpected argument: ${argument}.`);
}
const [name, inline] = argument.slice(2).split("=", 2);
values[name] = inline ?? argv[++index];
}
return values;
}
const positiveInteger = (value, label) => {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`${label} must be a positive integer.`);
}
return parsed;
};
async function collectTests(directory, repositoryRoot) {
const entries = await fs.readdir(directory, { withFileTypes: true });
const files = [];
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectTests(entryPath, repositoryRoot)));
} else if (testExtensions.has(path.extname(entry.name)) && /\.spec\.(?:js|ts)$/u.test(entry.name)) {
files.push(path.relative(repositoryRoot, entryPath).replace(/\\/gu, "/"));
}
}
return files;
}
export async function ownedTestsForNode(repositoryRoot, parsedCatalog, nodeId, graph) {
const allTests = await collectTests(path.join(repositoryRoot, "tests"), repositoryRoot);
return allTests
.filter((file) =>
testOwnersForPath(parsedCatalog, file).some(
(owner) => (graph?.originalToExecutionNode?.get(owner) || owner) === nodeId
)
)
.sort();
}
export const selectPartition = (files, partition, partitions) =>
files.filter((_, index) => index % partitions === partition - 1);
async function readSerialUnitTests(repositoryRoot) {
const contents = await fs.readFile(path.join(repositoryRoot, "tests/unit/serial-tests.txt"), "utf8");
return new Set(
contents
.split(/\r?\n/u)
.map((line) => line.replace(/#.*/u, "").trim())
.filter(Boolean)
);
}
async function runCommand(repositoryRoot, command, args, environment = {}) {
await new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: repositoryRoot,
env: { ...process.env, ...environment },
stdio: "inherit",
windowsHide: true,
});
child.on("error", reject);
child.on("close", (code, signal) => {
if (signal) {
reject(new Error(`${command} exited due to ${signal}.`));
} else if ((code ?? 1) !== 0) {
reject(new Error(`${command} exited with code ${code ?? 1}.`));
} else {
resolve();
}
});
});
}
const laneTestKind = (lane) => {
if (lane === "contract") return "unit";
if (lane.startsWith("ct-chromium-")) return "ct";
return "e2e";
};
export const eligibleTestsForLane = (ownedTests, lane) => {
const kind = laneTestKind(lane);
return ownedTests.filter((file) => file.startsWith(`tests/${kind}/`) && (kind !== "e2e" || isDefaultE2eFile(file)));
};
export function summarizePlaywrightExecution(report, expectedTests) {
const stats = report?.stats;
const counts = ["expected", "flaky", "skipped", "unexpected"];
if (!stats || counts.some((name) => !Number.isInteger(stats[name]) || stats[name] < 0)) {
throw new Error("Playwright JSON report has invalid outcome statistics.");
}
const total = counts.reduce((sum, name) => sum + stats[name], 0);
const executedTests = stats.expected + stats.flaky;
if (total !== expectedTests) {
throw new Error(`Playwright reported ${total} outcomes for ${expectedTests} listed tests.`);
}
if (stats.unexpected > 0 || executedTests + stats.skipped !== expectedTests) {
throw new Error(
`Playwright execution failed: ${executedTests} green, ${stats.skipped} intentionally skipped, ${stats.unexpected} unexpected of ${expectedTests}.`
);
}
return { executedTests, skippedTests: stats.skipped };
}
async function listBrowserTests(repositoryRoot, lane, selectedTests) {
const playwrightCli = path.join(repositoryRoot, "node_modules/@playwright/test/cli.js");
const kind = laneTestKind(lane);
const project = kind === "ct" ? lane.slice(3) : lane;
const configArgs = kind === "ct" ? ["--config=playwright.ct.config.ts"] : [];
const { stdout, stderr } = await execFileAsync(
process.execPath,
[playwrightCli, "test", ...configArgs, ...selectedTests, `--project=${project}`, "--list", "--reporter=list"],
{ cwd: repositoryRoot, maxBuffer: 64 * 1024 * 1024 }
);
if (stderr.trim()) process.stderr.write(stderr);
const listedTests = parseListedTests(stdout);
if (listedTests.length === 0) {
throw new Error(`Selected ${lane} files resolved zero Playwright tests.`);
}
const roles =
kind === "ct" ? ["contract"] : [...new Set(listedTests.map((testEntry) => classifyTest(testEntry)))].sort();
return { expectedTests: listedTests.length, roles, project, configArgs };
}
async function listUnitTests(repositoryRoot, selectedTests) {
const vitestCli = path.join(repositoryRoot, "node_modules/vitest/vitest.mjs");
const { stdout, stderr } = await execFileAsync(process.execPath, [vitestCli, "list", ...selectedTests, "--json"], {
cwd: repositoryRoot,
maxBuffer: 64 * 1024 * 1024,
});
if (stderr.trim()) process.stderr.write(stderr);
const listedTests = JSON.parse(stdout);
if (!Array.isArray(listedTests) || listedTests.length === 0) {
throw new Error("Selected unit files resolved zero Vitest tests.");
}
return listedTests.length;
}
export async function runNodeTests({ repositoryRoot, nodeId, lane, partition, partitions }) {
const { parsedCatalog, graph } = await loadRepositoryTestGraph(repositoryRoot);
const node = graph.nodes.get(nodeId);
if (!node) {
throw new Error(`Unknown component node: ${nodeId}.`);
}
if (!node.lanes.includes(lane)) {
throw new Error(`Component node ${nodeId} does not support lane ${lane}.`);
}
if (partition > partitions) {
throw new Error(`partition ${partition} exceeds partitions ${partitions}.`);
}
const configuredPartitions = node.partitions[lane] || 1;
if (partitions !== configuredPartitions) {
throw new Error(`${nodeId}/${lane} requires ${configuredPartitions} partition(s), received ${partitions}.`);
}
const ownedTests = await ownedTestsForNode(repositoryRoot, parsedCatalog, nodeId, graph);
const kind = laneTestKind(lane);
const eligibleTests = eligibleTestsForLane(ownedTests, lane);
const selectedTests = selectPartition(eligibleTests, partition, partitions);
if (selectedTests.length === 0) {
throw new Error(`Selected ${nodeId}/${lane} partition ${partition}/${partitions} resolved zero tests.`);
}
let expectedTests = selectedTests.length;
let roles = ["contract"];
let browserMetadata;
if (kind === "unit") {
expectedTests = await listUnitTests(repositoryRoot, selectedTests);
} else {
browserMetadata = await listBrowserTests(repositoryRoot, lane, selectedTests);
expectedTests = browserMetadata.expectedTests;
roles = browserMetadata.roles;
}
const result = {
nodeId,
lane,
partition,
partitions,
runAttempt: positiveInteger(process.env.GITHUB_RUN_ATTEMPT || "1", "GITHUB_RUN_ATTEMPT"),
expectedFiles: selectedTests.length,
expectedTests,
executedTests: 0,
skippedTests: 0,
roles,
status: "failed",
};
try {
if (kind === "unit") {
const serialAllowlist = await readSerialUnitTests(repositoryRoot);
const fastUnitTests = selectedTests.filter((file) => !serialAllowlist.has(file));
const serialUnitTests = selectedTests.filter((file) => serialAllowlist.has(file));
const vitestCli = path.join(repositoryRoot, "node_modules/vitest/vitest.mjs");
if (fastUnitTests.length > 0) {
await runCommand(repositoryRoot, process.execPath, [
vitestCli,
"run",
...fastUnitTests,
"--maxWorkers=3",
]);
}
for (const serialTest of serialUnitTests) {
await runCommand(repositoryRoot, process.execPath, [
vitestCli,
"run",
serialTest,
"--maxWorkers=1",
"--no-file-parallelism",
]);
}
} else {
const playwrightCli = path.join(repositoryRoot, "node_modules/@playwright/test/cli.js");
const artifactNamespace = process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `graph-${nodeId}-${lane}-${partition}`;
const jsonReportPath = path.join(repositoryRoot, "output", "playwright", artifactNamespace, "execution.json");
await fs.mkdir(path.dirname(jsonReportPath), { recursive: true });
await runCommand(
repositoryRoot,
process.execPath,
[
playwrightCli,
"test",
...browserMetadata.configArgs,
...selectedTests,
`--project=${browserMetadata.project}`,
"--workers=3",
],
{ PLAYWRIGHT_WORKERS: "3", PLAYWRIGHT_JSON_OUTPUT_FILE: jsonReportPath }
);
const report = JSON.parse(await fs.readFile(jsonReportPath, "utf8"));
Object.assign(result, summarizePlaywrightExecution(report, expectedTests));
}
result.status = "success";
if (kind === "unit") result.executedTests = expectedTests;
return result;
} catch (error) {
error.testGraphResult = result;
throw error;
}
}
const safeFilePart = (value) => value.replace(/[^a-zA-Z0-9_-]+/gu, "-");
export async function writePartitionResult(repositoryRoot, outputDirectory, result) {
const directory = path.resolve(repositoryRoot, outputDirectory || "output/test-graph-results");
await fs.mkdir(directory, { recursive: true });
const fileName = [
"dependency-ci-results",
safeFilePart(result.nodeId),
safeFilePart(result.lane),
`p${result.partition}-of-${result.partitions}.json`,
].join("-");
const output = path.join(directory, fileName);
await fs.writeFile(output, `${JSON.stringify(result, null, 2)}\n`, "utf8");
return output;
}
async function main() {
const args = readArgs(process.argv.slice(2));
const repositoryRoot = await fs.realpath(process.cwd());
const runOptions = {
repositoryRoot,
nodeId: args.node,
lane: args.lane,
partition: positiveInteger(args.partition || "1", "partition"),
partitions: positiveInteger(args.partitions || "1", "partitions"),
};
try {
const result = await runNodeTests(runOptions);
const output = await writePartitionResult(repositoryRoot, args["output-directory"], result);
console.log(`Wrote dependency CI partition result to ${output}.`);
} catch (error) {
if (error?.testGraphResult) {
const output = await writePartitionResult(repositoryRoot, args["output-directory"], error.testGraphResult);
console.error(`Wrote failed dependency CI partition result to ${output}.`);
}
throw error;
}
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
+94
View File
@@ -0,0 +1,94 @@
import fs from "node:fs/promises";
import path from "node:path";
import { testOwnersForPath } from "./graph.mjs";
import { ownedFilesByRole, titleRules } from "../run-playwright-full-slice.mjs";
const activeSpecPattern = /\.spec\.(?:js|ts)$/u;
async function collectSpecFiles(directory, repositoryRoot) {
const entries = await fs.readdir(directory, { withFileTypes: true });
const files = [];
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectSpecFiles(entryPath, repositoryRoot)));
} else if (activeSpecPattern.test(entry.name)) {
files.push(path.relative(repositoryRoot, entryPath).replace(/\\/gu, "/"));
}
}
return files;
}
const fileRoles = new Map();
for (const [role, files] of Object.entries(ownedFilesByRole)) {
for (const file of files) {
fileRoles.set(file, new Set([...(fileRoles.get(file) || []), role]));
}
}
for (const rule of titleRules) {
fileRoles.set(rule.file, new Set([...(fileRoles.get(rule.file) || []), rule.role]));
}
export const rolesForE2eFile = (file) => [...(fileRoles.get(path.basename(file)) || [])].sort();
const kindForLane = (lane) => {
if (lane === "contract") return "unit";
if (lane.startsWith("ct-")) return "ct";
return "e2e";
};
export const partitionFileLimits = {
// Keep every spec independently schedulable. This deliberately favors
// elapsed time and dependency isolation over runner/setup cost.
unit: 1,
ct: 1,
// Browser specs vary wildly in size. One file per matrix child keeps a
// large scenario from holding unrelated specs behind it and lets GitHub's
// hosted runner pool absorb all available E2E concurrency.
e2e: 1,
};
export const isDefaultE2eFile = (file) =>
file.startsWith("tests/e2e/") && !file.startsWith("tests/e2e/release/") && !file.startsWith("tests/e2e/quarantine/");
export async function attachTestInventory(repositoryRoot, parsedCatalog, graph) {
const allTests = await collectSpecFiles(path.join(repositoryRoot, "tests"), repositoryRoot);
const ownedFiles = new Map([...graph.nodes.keys()].map((nodeId) => [nodeId, []]));
for (const file of allTests) {
const owners = testOwnersForPath(parsedCatalog, file);
if (owners.length !== 1) {
throw new Error(
`${file} must have exactly one test owner; resolved ${owners.length === 0 ? "none" : owners.join(", ")}.`
);
}
const executionNode = graph.originalToExecutionNode.get(owners[0]);
ownedFiles.get(executionNode).push(file);
}
for (const [nodeId, node] of graph.nodes) {
node.ownedTests = [...ownedFiles.get(nodeId)].sort();
node.testInventory = {};
for (const lane of node.lanes) {
const kind = kindForLane(lane);
const files = node.ownedTests.filter(
(file) => file.startsWith(`tests/${kind}/`) && (kind !== "e2e" || isDefaultE2eFile(file))
);
const roles = kind === "e2e" ? [...new Set(files.flatMap(rolesForE2eFile))].sort() : ["contract"];
if (files.length === 0) {
throw new Error(`${nodeId}/${lane} resolves zero owned ${kind} test files.`);
}
if (kind === "e2e" && roles.length === 0) {
throw new Error(`${nodeId}/${lane} has no role classification for its E2E files.`);
}
node.testInventory[lane] = {
files,
expectedFiles: files.length,
roles,
};
const minimumPartitions = Math.ceil(files.length / partitionFileLimits[kind]);
node.partitions[lane] = Math.max(node.partitions[lane] || 1, minimumPartitions);
}
}
return graph;
}
@@ -0,0 +1,154 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GRAPH_VERSION } from "./component-node-catalog.mjs";
import { loadRepositoryTestGraph } from "./load-graph.mjs";
import { validateDependencyCiResults } from "./result-schema.mjs";
const requiredRoles = ["superuser", "admin", "customer", "subuser"];
const platformDefinitions = {
android: {
lanes: ["chromium-mobile", "ct-chromium-mobile"],
roleLane: "chromium-mobile",
},
apple: {
lanes: ["webkit-mobile"],
roleLane: "webkit-mobile",
},
};
export function normalizeStorePlatform(platform) {
const normalized = String(platform || "")
.trim()
.toLowerCase();
if (!platformDefinitions[normalized]) {
throw new Error(`Unsupported store platform: ${platform || "<empty>"}`);
}
return normalized;
}
export function expectedPlatformResults(platform, graph) {
const normalized = normalizeStorePlatform(platform);
if (!graph?.nodes || !Array.isArray(graph.topologicalOrder)) {
throw new Error("A resolved dependency graph is required for platform verification.");
}
return platformDefinitions[normalized].lanes.flatMap((lane) =>
graph.topologicalOrder
.map((nodeId) => graph.nodes.get(nodeId))
.filter((node) => node.lanes.includes(lane))
.map((node) => ({ nodeId: node.id, lane }))
);
}
export function evaluatePlatformResult(platform, manifest, graph) {
const normalized = normalizeStorePlatform(platform);
const definition = platformDefinitions[normalized];
const failures = [];
try {
validateDependencyCiResults(manifest);
} catch (error) {
failures.push(`dependency manifest:${error instanceof Error ? error.message : String(error)}`);
return { platform: normalized, lanes: definition.lanes, required: [], failures, passed: false };
}
if (manifest.profile !== "full") {
failures.push(`dependency manifest:profile ${manifest.profile} is not full`);
}
if (manifest.graphVersion !== GRAPH_VERSION) {
failures.push(`dependency manifest:graph version ${manifest.graphVersion} is not ${GRAPH_VERSION}`);
}
if (manifest.requiredCi !== true) {
failures.push("dependency manifest:Required CI failed");
}
const expected = expectedPlatformResults(normalized, graph);
const expectedKeys = new Set(expected.map(({ nodeId, lane }) => `${nodeId}|${lane}`));
const relevant = manifest.results.filter((result) => definition.lanes.includes(result.lane));
const byKey = new Map();
for (const result of relevant) {
const key = `${result.nodeId}|${result.lane}`;
if (!expectedKeys.has(key)) {
failures.push(`${result.nodeId}/${result.lane}:unexpected result`);
continue;
}
if (byKey.has(key)) {
failures.push(`${result.nodeId}/${result.lane}:duplicate result`);
continue;
}
byKey.set(key, result);
}
const coveredRoles = new Set();
for (const { nodeId, lane } of expected) {
const result = byKey.get(`${nodeId}|${lane}`);
if (!result) {
failures.push(`${nodeId}/${lane}:missing result`);
continue;
}
if (result.status !== "success") {
failures.push(`${nodeId}/${lane}:${result.status}`);
}
if (result.expectedTests < 1) {
failures.push(`${nodeId}/${lane}:no expected tests`);
}
if (result.executedTests + result.skippedTests !== result.expectedTests) {
failures.push(
`${nodeId}/${lane}:accounted for ${result.executedTests} green and ${result.skippedTests} intentionally skipped of ${result.expectedTests}`
);
}
if (lane === definition.roleLane) {
for (const role of result.roles) {
coveredRoles.add(role);
}
}
}
for (const role of requiredRoles) {
if (!coveredRoles.has(role)) {
failures.push(`${definition.roleLane}:role ${role} missing`);
}
}
return {
platform: normalized,
lanes: [...definition.lanes],
required: expected.map(({ nodeId, lane }) => `${nodeId}/${lane}`),
failures,
passed: failures.length === 0,
};
}
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value.startsWith("--")) throw new Error(`Unexpected argument: ${value}`);
const [name, inline] = value.slice(2).split("=", 2);
args[name] = inline ?? argv[++index];
}
return args;
}
export async function main(argv = process.argv.slice(2)) {
const args = parseArgs(argv);
if (!args.manifest) throw new Error("--manifest is required.");
const manifest = JSON.parse(await fs.readFile(path.resolve(args.manifest), "utf8"));
const { graph } = await loadRepositoryTestGraph(await fs.realpath(process.cwd()));
const result = evaluatePlatformResult(args.platform, manifest, graph);
if (!result.passed) {
throw new Error(`${result.platform} mobile test gate failed: ${result.failures.join(", ")}`);
}
console.log(`${result.platform} mobile test gate passed: ${result.required.join(", ")}`);
return result;
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
+95
View File
@@ -0,0 +1,95 @@
export const workflowJobId = (nodeId, lane) => `component_${nodeId}_${lane}`.replace(/-/gu, "_");
export const GENERATED_JOBS_BEGIN = " # BEGIN GENERATED DEPENDENCY-AWARE COMPONENT JOBS";
export const GENERATED_JOBS_END = " # END GENERATED DEPENDENCY-AWARE COMPONENT JOBS";
function runnablePrerequisiteJobs(graph, nodeId, lane, visited = new Set()) {
if (visited.has(nodeId)) {
return [];
}
visited.add(nodeId);
const prerequisite = graph.nodes.get(nodeId);
if (prerequisite.lanes.includes(lane)) {
return [workflowJobId(nodeId, lane)];
}
if (prerequisite.lanes.includes("contract")) {
return [workflowJobId(nodeId, "contract")];
}
return [...graph.dependencies.get(nodeId)].flatMap((dependency) =>
runnablePrerequisiteJobs(graph, dependency, lane, visited)
);
}
export function workflowDependencyJobIds(graph, nodeId, lane) {
const node = graph.nodes.get(nodeId);
const dependencies = new Set();
if (lane !== "contract" && node.lanes.includes("contract")) {
dependencies.add(workflowJobId(nodeId, "contract"));
}
for (const prerequisiteId of graph.dependencies.get(nodeId)) {
for (const prerequisiteJob of runnablePrerequisiteJobs(graph, prerequisiteId, lane)) {
dependencies.add(prerequisiteJob);
}
}
return [...dependencies].sort();
}
export function generateStaticComponentJobs(
graph,
{ planJobId = "plan_e2e", reusableWorkflow = "./.github/workflows/test-graph-node.yml", includeJobsKey = true } = {}
) {
const lines = includeJobsKey
? ["# Generated by scripts/test-graph/workflow-generator.mjs. Do not edit by hand.", "jobs:"]
: [];
for (const nodeId of graph.topologicalOrder) {
const node = graph.nodes.get(nodeId);
for (const lane of node.lanes) {
const jobId = workflowJobId(nodeId, lane);
const dependencies = workflowDependencyJobIds(graph, nodeId, lane);
lines.push(` ${jobId}:`);
lines.push(` name: ${JSON.stringify(`Component / ${node.label} / ${lane}`)}`);
lines.push(" needs:");
lines.push(` - ${planJobId}`);
if (lane !== "contract") {
lines.push(" - build");
}
for (const dependency of dependencies) {
lines.push(` - ${dependency}`);
}
lines.push(` if: \${{ contains(fromJSON(needs.${planJobId}.outputs.selected_jobs), '${jobId}') }}`);
lines.push(` uses: ${reusableWorkflow}`);
lines.push(" with:");
lines.push(` node: ${nodeId}`);
lines.push(` lane: ${lane}`);
const partitionCount = node.partitions[lane] || 1;
lines.push(
` partitions: "${JSON.stringify(
Array.from({ length: partitionCount }, (_, index) => index + 1)
).replaceAll('"', '\\"')}"`
);
lines.push(" permissions:");
lines.push(" contents: read");
}
}
return `${lines.join("\n")}\n`;
}
export function replaceGeneratedWorkflowSection(workflowSource, graph, options = {}) {
const begin = workflowSource.indexOf(GENERATED_JOBS_BEGIN);
const end = workflowSource.indexOf(GENERATED_JOBS_END);
if (begin === -1 || end === -1 || end < begin) {
throw new Error(
`Workflow must contain one ordered ${GENERATED_JOBS_BEGIN.trim()} / ${GENERATED_JOBS_END.trim()} marker pair.`
);
}
if (
workflowSource.indexOf(GENERATED_JOBS_BEGIN, begin + GENERATED_JOBS_BEGIN.length) !== -1 ||
workflowSource.indexOf(GENERATED_JOBS_END, end + GENERATED_JOBS_END.length) !== -1
) {
throw new Error("Workflow contains duplicate generated component job markers.");
}
const entries = generateStaticComponentJobs(graph, { ...options, includeJobsKey: false });
return `${workflowSource.slice(
0,
begin
)}${GENERATED_JOBS_BEGIN}\n${entries}${GENERATED_JOBS_END}${workflowSource.slice(end + GENERATED_JOBS_END.length)}`;
}
@@ -633,13 +633,30 @@ const clearBasket = () => {
}
const selectedProduct = ref<PosProduct | any>(null);
const translateBookingRestriction = (messageKey?: string | null) => {
switch (messageKey) {
case 'pos.restrictions.addons_not_allowed':
return t('pos.restrictions.addons_not_allowed');
case 'pos.restrictions.backend_rejected':
return t('pos.restrictions.backend_rejected');
case 'pos.restrictions.load_failed':
return t('pos.restrictions.load_failed');
case 'pos.restrictions.loading':
return t('pos.restrictions.loading');
case 'pos.restrictions.restricted_items_removed':
return t('pos.restrictions.restricted_items_removed');
default:
return t('pos.restrictions.product_not_allowed');
}
};
const clearRestrictedBookingSelection = (messageKey = 'pos.restrictions.product_not_allowed') => {
selectedProduct.value = null;
washInterior.value = false;
washExterior.value = true;
productAssociatedWithInteriorWash.value = null;
clearBasket();
bookingRestrictionWarning.value = t(messageKey);
bookingRestrictionWarning.value = translateBookingRestriction(messageKey);
};
const onSelectionInvalidated = (payload?: { restriction?: { messageKey?: string } }) => {
@@ -807,7 +824,7 @@ watch(() => customerNumber.value, () => { loadWashCertificateProduct(); });
const onClickInteriorWashButton = () => {
const restriction = getBookingAddonRestriction(interiorAddon.value);
if (!interiorAddon.value || restriction.restricted || !isWashCertificateAllowed.value) {
bookingRestrictionWarning.value = t(
bookingRestrictionWarning.value = translateBookingRestriction(
!isWashCertificateAllowed.value
? washCertificateRestriction.value.messageKey
: restriction.messageKey || 'pos.restrictions.product_not_allowed'
@@ -825,7 +842,7 @@ const onClickExteriorWashButton = () => {
: selectedProduct.value;
const restriction = getBookingProductRestriction(exteriorProduct);
if (!exteriorProduct || restriction.restricted) {
bookingRestrictionWarning.value = t(restriction.messageKey || 'pos.restrictions.product_not_allowed');
bookingRestrictionWarning.value = translateBookingRestriction(restriction.messageKey);
washExterior.value = false;
return;
}
@@ -1135,7 +1152,7 @@ const onSummaryConfirm = () => {
}
if (!guestMode.value && (!bookingRulesReady.value || completeBasket.value.length === 0)) {
showSummary.value = false;
bookingRestrictionWarning.value = t(
bookingRestrictionWarning.value = translateBookingRestriction(
bookingRulesReady.value
? 'pos.restrictions.product_not_allowed'
: customer_attributes_status.value === 'error'
@@ -21,8 +21,27 @@ const principalType = computed(
() => deletionState.value?.principal_type || (SessionUser.isSubuser.value ? "subuser" : "customer")
);
const canRequestDeletion = computed(() => deletionState.value?.status === "available" && !isSubmitting.value);
const statusKey = computed(() => `user_dashboard.profile.deletion.status.${deletionState.value?.status || "unavailable"}`);
const principalImpactKey = computed(() => `user_dashboard.profile.deletion.impact.${principalType.value}`);
const principalImpact = computed(() =>
principalType.value === "subuser"
? t("user_dashboard.profile.deletion.impact.subuser")
: t("user_dashboard.profile.deletion.impact.customer")
);
const statusLabel = computed(() => {
switch (deletionState.value?.status) {
case "available":
return t("user_dashboard.profile.deletion.status.available");
case "requested":
return t("user_dashboard.profile.deletion.status.requested");
case "processing":
return t("user_dashboard.profile.deletion.status.processing");
case "completed":
return t("user_dashboard.profile.deletion.status.completed");
case "failed":
return t("user_dashboard.profile.deletion.status.failed");
default:
return t("user_dashboard.profile.deletion.status.unavailable");
}
});
const retainedDataCategories = computed(() => {
const categories = deletionState.value?.retained_data_categories || [];
@@ -46,6 +65,15 @@ const retainedDataItems = computed(() => {
"customer_reference",
"driver_reference",
]);
const labels = {
invoices_payments_accounting: t("user_dashboard.profile.deletion.retained.invoices_payments_accounting"),
orders_wash_history: t("user_dashboard.profile.deletion.retained.orders_wash_history"),
security_audit_logs: t("user_dashboard.profile.deletion.retained.security_audit_logs"),
legal_obligations: t("user_dashboard.profile.deletion.retained.legal_obligations"),
customer_reference: t("user_dashboard.profile.deletion.retained.customer_reference"),
driver_reference: t("user_dashboard.profile.deletion.retained.driver_reference"),
other: t("user_dashboard.profile.deletion.retained.other"),
};
const items = retainedDataCategories.value.map((category) => {
const normalized = String(category || "")
.trim()
@@ -53,7 +81,7 @@ const retainedDataItems = computed(() => {
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
const key = knownCategories.has(normalized) ? normalized : "other";
return { key, label: t(`user_dashboard.profile.deletion.retained.${key}`) };
return { key, label: labels[key] };
});
return [...new Map(items.map((item) => [item.key, item])).values()];
@@ -173,7 +201,7 @@ const requestDeletion = async () => {
const warning = await Swal.fire({
title: t("user_dashboard.profile.deletion.warning_title"),
text: t(principalImpactKey.value),
text: principalImpact.value,
icon: "warning",
showCancelButton: true,
focusCancel: true,
@@ -261,7 +289,7 @@ onMounted(loadDeletionState);
<template v-else>
<div class="notification is-danger is-light mb-4">
<p class="has-text-weight-semibold mb-2">{{ t(principalImpactKey) }}</p>
<p class="has-text-weight-semibold mb-2">{{ principalImpact }}</p>
<p>{{ t("user_dashboard.profile.deletion.immediate_effect") }}</p>
</div>
@@ -279,7 +307,7 @@ onMounted(loadDeletionState);
<p class="mb-3" data-testid="account-deletion-status">
<strong>{{ t("user_dashboard.profile.deletion.status_label") }}:</strong>
{{ t(statusKey) }}
{{ statusLabel }}
</p>
<button
+25 -6
View File
@@ -5,9 +5,28 @@ import { useI18n } from "vue-i18n";
const { t } = useI18n();
const collectedDataKeys = ["account", "service", "content", "technical"];
const processorKeys = ["hosting", "economic", "stripe", "microsoft", "recaptcha"];
const rightsKeys = ["access", "correction", "erasure", "restriction", "portability", "objection", "complaint"];
const collectedDataItems = computed(() => [
{ key: "account", label: t("privacy_policy.data.account") },
{ key: "service", label: t("privacy_policy.data.service") },
{ key: "content", label: t("privacy_policy.data.content") },
{ key: "technical", label: t("privacy_policy.data.technical") },
]);
const processorItems = computed(() => [
{ key: "hosting", label: t("privacy_policy.processors.hosting") },
{ key: "economic", label: t("privacy_policy.processors.economic") },
{ key: "stripe", label: t("privacy_policy.processors.stripe") },
{ key: "microsoft", label: t("privacy_policy.processors.microsoft") },
{ key: "recaptcha", label: t("privacy_policy.processors.recaptcha") },
]);
const rightsItems = computed(() => [
{ key: "access", label: t("privacy_policy.rights.access") },
{ key: "correction", label: t("privacy_policy.rights.correction") },
{ key: "erasure", label: t("privacy_policy.rights.erasure") },
{ key: "restriction", label: t("privacy_policy.rights.restriction") },
{ key: "portability", label: t("privacy_policy.rights.portability") },
{ key: "objection", label: t("privacy_policy.rights.objection") },
{ key: "complaint", label: t("privacy_policy.rights.complaint") },
]);
useHead({
title: computed(() => `${t("privacy_policy.title")} - Truck Wash`),
@@ -43,7 +62,7 @@ useHead({
<h2>{{ t("privacy_policy.data_title") }}</h2>
<p>{{ t("privacy_policy.data_intro") }}</p>
<ul>
<li v-for="key in collectedDataKeys" :key="key">{{ t(`privacy_policy.data.${key}`) }}</li>
<li v-for="item in collectedDataItems" :key="item.key">{{ item.label }}</li>
</ul>
<p>{{ t("privacy_policy.permissions") }}</p>
@@ -53,7 +72,7 @@ useHead({
<h2>{{ t("privacy_policy.sharing_title") }}</h2>
<p>{{ t("privacy_policy.sharing_intro") }}</p>
<ul>
<li v-for="key in processorKeys" :key="key">{{ t(`privacy_policy.processors.${key}`) }}</li>
<li v-for="item in processorItems" :key="item.key">{{ item.label }}</li>
</ul>
<p>{{ t("privacy_policy.sharing_limit") }}</p>
@@ -76,7 +95,7 @@ useHead({
<h2>{{ t("privacy_policy.rights_title") }}</h2>
<p>{{ t("privacy_policy.rights_intro") }}</p>
<ul>
<li v-for="key in rightsKeys" :key="key">{{ t(`privacy_policy.rights.${key}`) }}</li>
<li v-for="item in rightsItems" :key="item.key">{{ item.label }}</li>
</ul>
<h2>{{ t("privacy_policy.changes_title") }}</h2>
+226
View File
@@ -0,0 +1,226 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
evaluateDependencyManifest,
evaluateLegacyStoreGate,
expectedLegacyStoreGateJobs,
verifyStoreTestGate,
} from "../../scripts/mobile/verify-store-test-gate.mjs";
import { expectedPlatformResults } from "../../scripts/test-graph/verify-platform-result.mjs";
import { GRAPH_VERSION } from "../../scripts/test-graph/component-node-catalog.mjs";
import { loadRepositoryTestGraph } from "../../scripts/test-graph/load-graph.mjs";
import { DEPENDENCY_CI_SCHEMA_VERSION } from "../../scripts/test-graph/result-schema.mjs";
const sourceSha = "a".repeat(40);
const root = process.cwd();
const successJob = (name) => ({ name, status: "completed", conclusion: "success" });
const roles = ["superuser", "admin", "customer", "subuser"];
let graph;
function manifest(platform = "android") {
return {
schemaVersion: DEPENDENCY_CI_SCHEMA_VERSION,
sourceSha,
profile: "full",
graphVersion: GRAPH_VERSION,
requiredCi: true,
results: expectedPlatformResults(platform, graph).map(({ nodeId, lane }) => ({
nodeId,
lane,
status: "success",
expectedTests: 3,
executedTests: 3,
skippedTests: 0,
roles: lane.startsWith("ct-") ? ["contract"] : [...roles],
})),
};
}
describe("mobile store dependency test gate", () => {
beforeAll(async () => {
({ graph } = await loadRepositoryTestGraph(process.cwd()));
});
it("wires exact mobile gates into hosted release workflows", () => {
const android = readFileSync(join(root, ".github/workflows/mobile-artifacts.yml"), "utf8");
const testflight = readFileSync(join(root, ".github/workflows/ios-testflight.yml"), "utf8");
const candidate = readFileSync(join(root, ".github/workflows/ios-app-store-candidate.yml"), "utf8");
const release = readFileSync(join(root, ".github/workflows/release.yml"), "utf8");
const rootRestore = readFileSync(join(root, ".github/workflows/cpanel-root-restore.yml"), "utf8");
expect(android).toContain("node scripts/mobile/verify-store-test-gate.mjs --platform android");
expect(android).toContain("github.event.workflow_run.id || ''");
expect(testflight).toContain("node scripts/mobile/verify-store-test-gate.mjs --platform apple");
expect(candidate).toContain("node scripts/mobile/verify-store-test-gate.mjs --platform apple");
expect(candidate).toContain("Tagged commit is not the exact current master SHA.");
expect(release).not.toContain("self-hosted");
expect(rootRestore).not.toContain("self-hosted");
});
it("requires complete successful Chromium mobile graph results and Required CI for Android", () => {
const result = evaluateDependencyManifest("android", manifest(), [successJob("Required CI")], graph);
expect(result).toMatchObject({ lane: "chromium-mobile", passed: true, source: "manifest" });
expect(result.lanes).toEqual(["chromium-mobile", "ct-chromium-mobile"]);
expect(result.required).toHaveLength(expectedPlatformResults("android", graph).length);
});
it("requires WebKit mobile graph results for Apple", () => {
const result = evaluateDependencyManifest("apple", manifest("apple"), [successJob("Required CI")], graph);
expect(result).toMatchObject({ lane: "webkit-mobile", passed: true, source: "manifest" });
});
it.each([
["failed", 3, 3, "runtime-router/chromium-mobile:failed"],
["dependency-blocked", 3, 0, "runtime-router/chromium-mobile:dependency-blocked"],
["success", 3, 2, "runtime-router/chromium-mobile:accounted for 2 green and 0 intentionally skipped of 3"],
])("rejects %s or incomplete expected results", (status, expectedTests, executedTests, failure) => {
const input = manifest();
const index = input.results.findIndex(
(result) => result.nodeId === "runtime-router" && result.lane === "chromium-mobile"
);
input.results[index] = { ...input.results[index], status, expectedTests, executedTests };
const result = evaluateDependencyManifest("android", input, [successJob("Required CI")], graph);
expect(result.passed).toBe(false);
expect(result.failures).toContain(failure);
});
it("rejects a missing role and a failed actual Required CI job", () => {
const input = manifest();
input.results = input.results.map((result) => ({
...result,
roles: result.roles.filter((role) => role !== "subuser"),
}));
const result = evaluateDependencyManifest(
"android",
input,
[{ name: "Required CI", status: "completed", conclusion: "failure" }],
graph
);
expect(result.failures).toEqual(
expect.arrayContaining(["Required CI:completed/failure", "chromium-mobile:role subuser missing"])
);
});
it("rejects omitted nodes, omitted Chromium CT, partial profiles, and stale graph versions", () => {
const input = manifest();
const chromiumCtNode = expectedPlatformResults("android", graph).find(
(entry) => entry.lane === "ct-chromium-mobile"
).nodeId;
input.profile = "pr";
input.graphVersion = 99;
input.results = input.results.filter(
(result) =>
!(result.nodeId === "runtime-router" && result.lane === "chromium-mobile") &&
result.lane !== "ct-chromium-mobile"
);
const result = evaluateDependencyManifest("android", input, [successJob("Required CI")], graph);
expect(result.failures).toEqual(
expect.arrayContaining([
"dependency manifest:profile pr is not full",
`dependency manifest:graph version 99 is not ${GRAPH_VERSION}`,
"runtime-router/chromium-mobile:missing result",
`${chromiumCtNode}/ct-chromium-mobile:missing result`,
])
);
});
it("retains one-cycle compatibility with complete legacy role jobs", () => {
const jobs = [successJob("Required CI"), ...expectedLegacyStoreGateJobs("android").map(successJob)];
expect(evaluateLegacyStoreGate("android", jobs)).toMatchObject({ passed: true, source: "legacy" });
});
it("requires every legacy shard when sharded job names are present", () => {
const jobs = [successJob("Required CI")];
for (const name of expectedLegacyStoreGateJobs("apple")) {
jobs.push(successJob(`${name}-1of2`), successJob(`${name}-2of2`));
}
jobs.find((job) => job.name.includes("admin-2of2")).conclusion = "failure";
const result = evaluateLegacyStoreGate("apple", jobs);
expect(result.passed).toBe(false);
expect(result.failures).toContain("E2E-full-WebKit-mobile-admin-2of2:completed/failure");
});
it("loads the exact run manifest artifact and refuses legacy fallback when it exists", async () => {
const failingManifest = manifest();
const failedResult = failingManifest.results.find(
(result) => result.nodeId === "runtime-router" && result.lane === "chromium-mobile"
);
failedResult.status = "failed";
const fetchImpl = vi.fn(async (url) => {
if (url.includes(`/actions/runs/77/jobs?`)) {
return Response.json({
jobs: [successJob("Required CI"), ...expectedLegacyStoreGateJobs("android").map(successJob)],
});
}
if (url.includes(`/actions/runs/77/artifacts?`)) {
return Response.json({
artifacts: [
{ id: 90, name: `dependency-ci-results-${sourceSha}-1`, expired: false },
{ id: 91, name: `dependency-ci-results-${sourceSha}-2`, expired: false },
],
});
}
if (url.endsWith("/actions/artifacts/91/zip")) {
return new Response(new Uint8Array([1, 2, 3]));
}
if (url.endsWith("/actions/runs/77")) {
return Response.json({
id: 77,
name: "Automated Tests",
head_sha: sourceSha,
head_branch: "master",
event: "push",
status: "completed",
});
}
throw new Error(`Unexpected URL: ${url}`);
});
await expect(
verifyStoreTestGate({
platform: "android",
sourceSha,
runId: "77",
repository: "example/repo",
token: "test-token",
fetchImpl,
extractManifest: async () => failingManifest,
})
).rejects.toThrow("runtime-router/chromium-mobile:failed");
}, 15_000);
it("rejects a test run for a different source SHA", async () => {
const fetchImpl = vi.fn(async () =>
Response.json({
id: 77,
name: "Automated Tests",
head_sha: "b".repeat(40),
head_branch: "master",
event: "push",
status: "completed",
})
);
await expect(
verifyStoreTestGate({
platform: "apple",
sourceSha,
runId: "77",
repository: "example/repo",
token: "test-token",
fetchImpl,
})
).rejects.toThrow(`not ${sourceSha}`);
});
});
+66 -67
View File
@@ -1,106 +1,105 @@
import { readFileSync } from "node:fs";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { format } from "prettier";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const workflowSource = () => readFileSync(join(root, ".github/workflows/tests.yml"), "utf8");
const networkFixtureSource = () => readFileSync(join(root, "tests/e2e/support/network.js"), "utf8");
describe("Playwright full E2E workflow grouping", () => {
it("orders full-suite matrix dimensions by browser, device, then role", () => {
const source = workflowSource();
expect(source).toContain("name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}");
expect(source).toContain("browser: [chromium, webkit, firefox]");
expect(source).toContain("device: [mobile, desktop, tablet]");
expect(source).toContain("role: [superuser, admin, customer, subuser]");
describe("dependency-aware Playwright workflow", () => {
it("is valid YAML after deterministic generation", async () => {
const formatted = await format(workflowSource(), { parser: "yaml" });
expect(formatted).toContain("name: Automated Tests");
});
it("uses browser-device-role artifact namespaces and generated test lists", () => {
it("replaces the coarse PR, targeted, and full matrices with component-lane jobs", () => {
const source = workflowSource();
expect(source).toContain(
"PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}"
);
expect(source).toContain(
"name: playwright-report-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}"
);
expect(source).toContain(
"output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}.txt"
);
expect(source).not.toContain("E2E-full-");
expect(source).not.toContain("e2e-pr:");
expect(source).not.toContain("e2e-targeted:");
expect(source).toContain("component_component_action_settings_wheel_contract:");
expect(source).toMatch(/component_atomic_view_admin_[a-f0-9]+_chromium_mobile:/u);
expect(source).toContain("uses: ./.github/workflows/test-graph-node.yml");
});
it("keeps self-hosted full-suite runner pressure bounded while hosted dispatch can fan out", () => {
it("runs the complete nine-project browser-device graph in full mode", () => {
const source = workflowSource();
expect(source).toContain("FRONTEND_CI_STANDARD_RUNNER");
expect(source).toContain("FRONTEND_CI_E2E_RUNNER");
expect(source).toContain("FRONTEND_CI_PR_E2E_MAX_PARALLEL");
expect(source).toContain("FRONTEND_CI_FULL_E2E_MAX_PARALLEL");
expect(source).toMatch(/e2e-pr:[\s\S]*?max-parallel: \$\{\{ fromJSON\(vars\.FRONTEND_CI_PR_E2E_MAX_PARALLEL/u);
expect(source).toMatch(/e2e-full:[\s\S]*?max-parallel: \$\{\{ fromJSON\(vars\.FRONTEND_CI_FULL_E2E_MAX_PARALLEL/u);
expect(source).toMatch(/e2e-full:[\s\S]*?PLAYWRIGHT_WORKERS: 1/u);
expect(source).toMatch(/e2e-full:[\s\S]*?PLAYWRIGHT_VIDEO_MODE: off/u);
expect(source).toContain("scripts/ci/with-systemd-inhibit.sh");
expect(source).toContain("scripts/ci/runner-diagnostics.sh");
for (const lane of [
"chromium-mobile",
"chromium-desktop",
"chromium-tablet",
"webkit-mobile",
"webkit-desktop",
"webkit-tablet",
"firefox-mobile",
"firefox-desktop",
"firefox-tablet",
]) {
expect(source).toContain(`lane: ${lane}`);
}
});
it("diffs pull request changed-area tests against the PR head instead of the merge commit", () => {
const source = workflowSource();
expect(source).toContain("PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}");
expect(source).toMatch(
/if \[\[ "\$EVENT_NAME" == "pull_request" && -n "\$PR_BASE_SHA" \]\]; then[\s\S]*?head_ref="\$PR_HEAD_SHA"/u
);
expect(source).toContain('echo "head=$head_ref" >> "$GITHUB_OUTPUT"');
expect(source).toContain("BASE_SHA: ${{ github.event.pull_request.base.sha || '' }}");
expect(source).toContain("HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}");
expect(source).toContain('git diff --name-only "$BASE_SHA" "$HEAD_SHA"');
});
it("keeps PR E2E runner pressure bounded and diagnosable", () => {
it("uses GitHub-hosted runners and permits high parallel fan-out", () => {
const workflowDirectory = join(root, ".github/workflows");
const allWorkflows = readdirSync(workflowDirectory)
.filter((file) => /\.ya?ml$/u.test(file))
.map((file) => readFileSync(join(workflowDirectory, file), "utf8"))
.join("\n");
const reusable = readFileSync(join(workflowDirectory, "test-graph-node.yml"), "utf8");
expect(allWorkflows).not.toContain("self-hosted");
expect(workflowSource()).not.toContain("FRONTEND_CI_RUNNER_JSON");
expect(reusable).toContain("runs-on: ubuntu-24.04");
expect(reusable).not.toContain("runner_json");
expect(reusable).toContain("max-parallel: 100");
expect(reusable).toContain("PLAYWRIGHT_WORKERS: 3");
expect(workflowSource()).toContain("cancel-in-progress: true");
expect(workflowSource()).toContain("group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-");
});
it("keeps all repository quality guards as independent concurrent checks", () => {
const source = workflowSource();
expect(source).toContain("FRONTEND_CI_E2E_RUNNER");
expect(source).toMatch(
/e2e-pr:[\s\S]*?max-parallel: \$\{\{ fromJSON\(vars\.FRONTEND_CI_PR_E2E_MAX_PARALLEL \|\| '2'\) \}\}/u
);
expect(source).toMatch(/e2e-pr:[\s\S]*?PLAYWRIGHT_WORKERS: 1/u);
expect(source).toMatch(/e2e-pr:[\s\S]*?PLAYWRIGHT_VIDEO_MODE: on-first-retry/u);
expect(source).toMatch(/e2e-pr:[\s\S]*?--env PLAYWRIGHT_WORKERS="\$PLAYWRIGHT_WORKERS"/u);
expect(source).toMatch(/e2e-pr:[\s\S]*?--env PLAYWRIGHT_VIDEO_MODE="\$PLAYWRIGHT_VIDEO_MODE"/u);
expect(source).toContain("check: [format, lint, i18n, encoding, ai-sync]");
expect(source).toContain("encoding) npm run text:check-encoding");
expect(source).toContain("ai-sync) node scripts/sync-ai-workflow.mjs --check");
});
it("supports repo-variable runner controls while keeping targeted reruns on Ubuntu", () => {
const source = workflowSource();
expect(source).toContain("FRONTEND_CI_STANDARD_RUNNER");
expect(source).toContain("FRONTEND_CI_E2E_RUNNER");
expect(source).toContain('["self-hosted","Linux","X64","pleno","frontend"]');
expect(source).toMatch(/e2e-targeted:[\s\S]*?runs-on: ubuntu-24\.04/u);
expect(source).toMatch(/e2e-pr:[\s\S]*?runs-on: \$\{\{ fromJSON\(vars\.FRONTEND_CI_E2E_RUNNER/u);
expect(source).toMatch(/e2e-full:[\s\S]*?frontend","docker"\]/u);
});
it("supports targeted manual Playwright reruns on GitHub-hosted runners", () => {
it("supports dependency-aware targeted manual reruns", () => {
const source = workflowSource();
expect(source).toContain("workflow_dispatch:");
expect(source).toContain("targeted-then-full");
expect(source).toContain("target_nodes:");
expect(source).toContain("target_specs:");
expect(source).toContain("target_projects:");
expect(source).toContain("e2e-targeted:");
expect(source).toContain("matrix:");
expect(source).toContain("project: ${{ fromJSON(inputs.target_projects || '[\"chromium-desktop\"]') }}");
expect(source).toMatch(/e2e-targeted:[\s\S]*?runs-on: ubuntu-24\.04/u);
expect(source).toContain('npx playwright test "${args[@]}"');
expect(source).toContain('"$spec_path" != tests/e2e/*');
expect(source).toMatch(/e2e-pr:[\s\S]*?needs: \[build-and-unit, e2e-targeted\]/u);
expect(source).toContain('--nodes "$targets"');
expect(source).toContain('--specs "$TARGET_SPECS"');
expect(source).toContain('--lanes "$lanes"');
});
it("uses a machine-wide Playwright port lock across self-hosted runner processes", () => {
it("publishes stable required and platform gate names", () => {
const source = workflowSource();
expect(source.match(/PLAYWRIGHT_PORT_LOCK_ROOT:-\/tmp\/pleno-playwright-port-locks/gu)).toHaveLength(3);
expect(source.match(/chmod 1777 "\$lock_root"/gu)).toHaveLength(3);
expect(source).not.toContain("${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks");
expect(source).toContain("name: Required CI");
expect(source).toContain("name: Mobile E2E gate - Android Chromium mobile");
expect(source).toContain("name: Mobile E2E gate - Apple WebKit mobile");
expect(source).toContain("verify-platform-result.mjs --platform android");
expect(source).toContain("verify-platform-result.mjs --platform apple");
expect(source).toContain("pattern: dependency-ci-plan-${{ github.run_id }}-*");
expect(source).toContain("pattern: test-graph-result-*-${{ github.run_id }}-*");
expect(source).toContain("name: dependency-ci-results-${{ github.sha }}-${{ github.run_attempt }}");
});
it("allows CI to reduce Playwright video artifact pressure", () => {
+4 -1
View File
@@ -36,8 +36,11 @@ describe("Playwright E2E quarantine", () => {
it("keeps component coverage wired into local and CI gates", () => {
const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
const workflowSource = readFileSync(join(root, ".github/workflows/tests.yml"), "utf8");
const reusableWorkflowSource = readFileSync(join(root, ".github/workflows/test-graph-node.yml"), "utf8");
expect(packageJson.scripts["test:all"]).toContain("npm run test:ct:pr");
expect(workflowSource).toContain('npm run test:ct -- --project="$MATRIX_PROJECT"');
expect(workflowSource).toMatch(/component_atomic_component_date_period_selector_[a-f0-9]+_ct_chromium_desktop:/u);
expect(workflowSource).toMatch(/component_atomic_component_date_period_selector_[a-f0-9]+_ct_chromium_mobile:/u);
expect(reusableWorkflowSource).toContain("scripts/test-graph/run-node-tests.mjs");
});
});
+226
View File
@@ -0,0 +1,226 @@
import { beforeAll, describe, expect, it } from "vitest";
import { componentNodeCatalog, compositionImportRules } from "../../scripts/test-graph/component-node-catalog.mjs";
import {
createDependencyGraph,
parseCatalog,
prerequisiteClosure,
sourceOwnersForPath,
testOwnersForPath,
} from "../../scripts/test-graph/graph.mjs";
import { classifySemanticImport } from "../../scripts/test-graph/import-resolver.mjs";
import { loadRepositoryTestGraph } from "../../scripts/test-graph/load-graph.mjs";
import { planComponentSelection } from "../../scripts/test-graph/planner.mjs";
import { partitionFileLimits } from "../../scripts/test-graph/test-inventory.mjs";
describe("dependency-aware component graph", () => {
let parsedCatalog;
let graph;
let importGraph;
beforeAll(async () => {
({ parsedCatalog, graph, importGraph } = await loadRepositoryTestGraph(process.cwd()));
});
it("keeps exact reusable component families as independent contracts", () => {
expect(
sourceOwnersForPath(parsedCatalog, "src/components/displays/buttons/ActionSettingsWheelToggleItem.vue")
).toEqual(["component-action-settings-wheel"]);
expect(sourceOwnersForPath(parsedCatalog, "src/components/displays/buttons/DatePeriodSelector.vue")).toEqual([
"component-date-period-selector",
]);
expect(testOwnersForPath(parsedCatalog, "tests/ct/date-period-selector.ct.spec.ts")).toEqual([
"component-date-period-selector",
]);
expect(testOwnersForPath(parsedCatalog, "tests/e2e/pos-customer-rules.spec.js")).toEqual(["component-pos"]);
expect(testOwnersForPath(parsedCatalog, "tests/e2e/edge-gateways.smoke.spec.js")).toEqual([
"component-edge-gateway",
]);
const datePeriodExecutionNode = graph.originalToExecutionNode.get("component-date-period-selector");
expect(graph.nodes.get(datePeriodExecutionNode).lanes).toEqual(
expect.arrayContaining(["contract", "ct-chromium-desktop", "ct-chromium-mobile"])
);
});
it("assigns every spec exactly one owner and gives every lane relevant tests", () => {
for (const node of graph.nodes.values()) {
for (const lane of node.lanes) {
expect(node.testInventory[lane].expectedFiles, `${node.id}/${lane}`).toBeGreaterThan(0);
}
}
expect(graph.sccReport).toEqual(
expect.arrayContaining([
expect.objectContaining({
members: expect.arrayContaining(["component-date-period-selector", "component-pagination"]),
}),
])
);
});
it("runs every relevant spec in its own hosted-runner matrix child", () => {
expect(partitionFileLimits.unit).toBe(1);
expect(partitionFileLimits.ct).toBe(1);
expect(partitionFileLimits.e2e).toBe(1);
for (const node of graph.nodes.values()) {
for (const lane of node.lanes) {
expect(node.partitions[lane], `${node.id}/${lane}`).toBe(node.testInventory[lane].expectedFiles);
}
}
});
it("represents every deliberately ignored cross-owner import through an explicit prerequisite", () => {
for (const edge of importGraph.compositionImports.filter((entry) => entry.mode === "ignore")) {
const consumer = graph.originalToExecutionNode.get(edge.from);
const provider = graph.originalToExecutionNode.get(edge.to);
expect(
prerequisiteClosure(graph, new Set([consumer])).has(provider),
`${edge.importer} -> ${edge.imported}`
).toBe(true);
}
});
it("uses semantic layers to invert provider-to-consumer imports", () => {
const rule = classifySemanticImport(parsedCatalog, "service-session-storage", "component-session");
expect(rule).toMatchObject({ mode: "invert" });
expect(compositionImportRules).toEqual(
expect.arrayContaining([expect.objectContaining({ importer: "src/router.js", mode: "invert" })])
);
});
it("resolves Vite-root asset imports as real consumer prerequisites", () => {
const dependency = importGraph.dependencies.find(
(entry) => entry.from === "component-pos" && entry.to === "runtime-assets"
);
expect(dependency?.imports).toEqual(
expect.arrayContaining([
expect.objectContaining({
importer: "src/components/displays/department/pos/displays/Piktogrammer.vue",
imported: "src/assets/piktogrammer/placeholder.png",
specifier: "/src/assets/piktogrammer/placeholder.png",
}),
])
);
const posNode = graph.originalToExecutionNode.get("component-pos");
expect(prerequisiteClosure(graph, new Set([posNode]))).toContain("runtime-assets");
});
it("selects prerequisites and reverse transitive dependents for source targets", () => {
const plan = planComponentSelection({
graph,
parsedCatalog,
profile: "targeted",
changedFiles: ["src/components/displays/buttons/ActionSettingsWheelButton.vue"],
});
expect(plan.selectedNodes).toContain("component-action-settings-wheel");
expect(plan.selectedNodes).toContain(graph.originalToExecutionNode.get("component-pos"));
expect(plan.selectedNodes).toContain(graph.originalToExecutionNode.get("view-superuser"));
expect(plan.selectedJobs.every((job) => job.expectedFiles > 0)).toBe(true);
expect(plan.selectedJobs.every((job) => job.roles.length > 0)).toBe(true);
const explicitTarget = planComponentSelection({
graph,
parsedCatalog,
profile: "targeted",
targetNodes: ["component-action-settings-wheel"],
});
expect(explicitTarget.selectedNodes).toContain(graph.originalToExecutionNode.get("component-pos"));
expect(explicitTarget.selectedNodes).toContain(graph.originalToExecutionNode.get("view-superuser"));
const datePeriodTarget = planComponentSelection({
graph,
parsedCatalog,
profile: "targeted",
targetNodes: ["component-date-period-selector"],
targetLanes: ["chromium-mobile"],
});
expect(datePeriodTarget.selectedJobs.some((job) => job.lane === "ct-chromium-mobile")).toBe(true);
expect(() =>
planComponentSelection({
graph,
parsedCatalog,
profile: "targeted",
targetNodes: ["component-date-period-selector"],
targetLanes: ["chromium-television"],
})
).toThrow("Unknown target browser lane");
});
it("does not fan out test-only changes and fails closed for unknown runtime source", () => {
const testOnly = planComponentSelection({
graph,
parsedCatalog,
profile: "pr",
changedFiles: ["tests/unit/action-settings-wheel-button.spec.js"],
});
expect(testOnly.selectedNodes).toContain("component-action-settings-wheel");
expect(testOnly.selectedNodes).not.toContain(graph.originalToExecutionNode.get("view-superuser"));
const failClosed = planComponentSelection({
graph,
parsedCatalog,
profile: "pr",
changedFiles: ["src/new-unowned-runtime.js"],
});
expect(failClosed.fullGraph).toBe(true);
expect(failClosed.selectedNodes).toHaveLength(graph.nodes.size);
for (const sharedInfrastructure of [
"tests/e2e/support/network.js",
"vitest.config.js",
"package-lock.json",
"capacitor.config.ts",
"Dockerfile.coolify-frontend",
"jsconfig.json",
"tsconfig.json",
"scripts/mobile/verify-store-test-gate.mjs",
"tests/unit/serial-tests.txt",
]) {
const infrastructurePlan = planComponentSelection({
graph,
parsedCatalog,
profile: "pr",
changedFiles: [sharedInfrastructure],
});
expect(infrastructurePlan.fullGraph, sharedInfrastructure).toBe(true);
}
});
it("turns an empty targeted request into a complete full-profile graph", () => {
const plan = planComponentSelection({
graph,
parsedCatalog,
profile: "targeted",
});
expect(plan.profile).toBe("full");
expect(plan.fullGraph).toBe(true);
expect(plan.selectedNodes).toHaveLength(graph.nodes.size);
expect(plan.selectedJobs.some((job) => job.lane === "firefox-tablet")).toBe(true);
});
it("collapses a genuine cycle into one atomic execution node", () => {
const tinyCatalog = parseCatalog([
{
id: "service-a",
label: "A",
kind: "service",
layer: 10,
sourcePatterns: ["src/a.js"],
testPatterns: ["tests/unit/a.spec.js"],
lanes: ["contract"],
dependsOn: ["service-b"],
},
{
id: "service-b",
label: "B",
kind: "service",
layer: 10,
sourcePatterns: ["src/b.js"],
testPatterns: ["tests/unit/b.spec.js"],
lanes: ["contract"],
dependsOn: ["service-a"],
},
]);
const collapsed = createDependencyGraph(tinyCatalog, [], { collapseCycles: true });
expect(collapsed.nodes).toHaveLength(1);
expect(collapsed.sccReport[0].members).toEqual(["service-a", "service-b"]);
});
});
+229
View File
@@ -0,0 +1,229 @@
import { beforeAll, describe, expect, it } from "vitest";
import { aggregateDependencyCiResults } from "../../scripts/test-graph/aggregate-results.mjs";
import { loadRepositoryTestGraph } from "../../scripts/test-graph/load-graph.mjs";
import { planComponentSelection } from "../../scripts/test-graph/planner.mjs";
import {
eligibleTestsForLane,
ownedTestsForNode,
selectPartition,
summarizePlaywrightExecution,
} from "../../scripts/test-graph/run-node-tests.mjs";
import {
GENERATED_JOBS_BEGIN,
GENERATED_JOBS_END,
generateStaticComponentJobs,
replaceGeneratedWorkflowSection,
workflowDependencyJobIds,
workflowJobId,
} from "../../scripts/test-graph/workflow-generator.mjs";
import { partitionFileLimits } from "../../scripts/test-graph/test-inventory.mjs";
const passingInfrastructure = {
plan_e2e: { result: "success" },
quality: { result: "success" },
build: { result: "success" },
};
describe("dependency-aware workflow execution", () => {
let parsedCatalog;
let graph;
beforeAll(async () => {
({ parsedCatalog, graph } = await loadRepositoryTestGraph(process.cwd()));
});
it("generates static reusable jobs with explicit needs and GitHub-hosted runners", () => {
const generated = generateStaticComponentJobs(graph);
const viewNode = graph.originalToExecutionNode.get("view-superuser");
const viewJob = workflowJobId(viewNode, "chromium-mobile");
const wheelJob = workflowJobId("component-action-settings-wheel", "contract");
expect(workflowDependencyJobIds(graph, viewNode, "chromium-mobile")).toContain(wheelJob);
expect(generated).toContain(` ${viewJob}:`);
expect(generated).toContain(` - ${wheelJob}`);
expect(generated.slice(generated.indexOf(` ${viewJob}:`))).toContain(" - build");
expect(generated).toContain("contains(fromJSON(needs.plan_e2e.outputs.selected_jobs)");
expect(generated).toContain("uses: ./.github/workflows/test-graph-node.yml");
expect(generated).not.toContain("runner_json");
expect(generated).not.toContain("self-hosted");
});
it("replaces only the deterministic generated marker section", () => {
const source = `name: Test\njobs:\n${GENERATED_JOBS_BEGIN}\n stale: true\n${GENERATED_JOBS_END}\n`;
const replaced = replaceGeneratedWorkflowSection(source, graph);
expect(replaced).toContain("name: Test\njobs:\n");
expect(replaced).not.toContain("stale: true");
expect(replaced).toContain(workflowJobId("component-action-settings-wheel", "contract"));
});
it("writes complete and fail-closed aggregate states from partition evidence", () => {
const jobId = workflowJobId("component-action-settings-wheel", "contract");
const plan = {
profile: "pr",
selectedJobs: [
{
jobId,
nodeId: "component-action-settings-wheel",
lane: "contract",
partitions: 1,
expectedFiles: 2,
roles: ["contract"],
},
],
};
const evidence = [
{
nodeId: "component-action-settings-wheel",
lane: "contract",
partition: 1,
partitions: 1,
runAttempt: 1,
expectedFiles: 2,
expectedTests: 2,
executedTests: 2,
skippedTests: 0,
roles: ["contract"],
status: "success",
},
];
const success = aggregateDependencyCiResults({
graph,
plan,
needs: { ...passingInfrastructure, [jobId]: { result: "success" } },
partitionResults: evidence,
sourceSha: "abc123",
});
expect(success.requiredCi).toBe(true);
expect(success.results.find((result) => result.nodeId === "component-action-settings-wheel")).toMatchObject({
status: "success",
expectedTests: 2,
executedTests: 2,
});
const missing = aggregateDependencyCiResults({
graph,
plan,
needs: { ...passingInfrastructure, [jobId]: { result: "success" } },
partitionResults: [],
sourceSha: "abc123",
});
expect(missing.requiredCi).toBe(false);
expect(missing.results.find((result) => result.nodeId === "component-action-settings-wheel").status).toBe(
"missing"
);
const qualityFailed = aggregateDependencyCiResults({
graph,
plan,
needs: {
...passingInfrastructure,
quality: { result: "failure" },
[jobId]: { result: "success" },
},
partitionResults: evidence,
sourceSha: "abc123",
});
expect(qualityFailed.requiredCi).toBe(false);
expect(qualityFailed.infrastructure.quality).toBe("failed");
const inventoryMismatch = aggregateDependencyCiResults({
graph,
plan,
needs: { ...passingInfrastructure, [jobId]: { result: "success" } },
partitionResults: [{ ...evidence[0], expectedFiles: 1 }],
sourceSha: "abc123",
});
expect(inventoryMismatch.requiredCi).toBe(false);
expect(inventoryMismatch.results.find((result) => result.nodeId === "component-action-settings-wheel").status).toBe(
"missing"
);
const rerun = aggregateDependencyCiResults({
graph,
plan,
needs: { ...passingInfrastructure, [jobId]: { result: "success" } },
partitionResults: [
{ ...evidence[0], status: "failed", executedTests: 0 },
{ ...evidence[0], runAttempt: 2 },
],
sourceSha: "abc123",
});
expect(rerun.requiredCi).toBe(true);
});
it("marks selected skipped jobs dependency-blocked", () => {
const nodeId = graph.originalToExecutionNode.get("view-superuser");
const lane = "chromium-mobile";
const jobId = workflowJobId(nodeId, lane);
const plan = {
profile: "pr",
selectedJobs: [{ jobId, nodeId, lane, partitions: 1, roles: ["superuser"] }],
};
const blocked = aggregateDependencyCiResults({
graph,
plan,
needs: { ...passingInfrastructure, [jobId]: "skipped" },
sourceSha: "abc123",
});
expect(blocked.results.find((result) => result.nodeId === nodeId && result.lane === lane).status).toBe(
"dependency-blocked"
);
});
it("resolves exact lane ownership and deterministic partitions", async () => {
const tests = await ownedTestsForNode(process.cwd(), parsedCatalog, "component-action-settings-wheel", graph);
expect(tests).toEqual([
"tests/unit/action-settings-wheel-button.spec.js",
"tests/unit/i18n-action-settings-wheel.spec.js",
]);
expect(selectPartition(["a", "b", "c", "d"], 2, 2)).toEqual(["b", "d"]);
expect(
eligibleTestsForLane(
["tests/e2e/active.spec.js", "tests/e2e/release/production.spec.js", "tests/e2e/quarantine/retired.spec.js"],
"chromium-mobile"
)
).toEqual(["tests/e2e/active.spec.js"]);
expect(summarizePlaywrightExecution({ stats: { expected: 2, flaky: 1, skipped: 0, unexpected: 0 } }, 3)).toEqual({
executedTests: 3,
skippedTests: 0,
});
expect(summarizePlaywrightExecution({ stats: { expected: 2, flaky: 0, skipped: 1, unexpected: 0 } }, 3)).toEqual({
executedTests: 2,
skippedTests: 1,
});
expect(() =>
summarizePlaywrightExecution({ stats: { expected: 2, flaky: 0, skipped: 0, unexpected: 1 } }, 3)
).toThrow("1 unexpected");
});
it("partitions heavy nodes into small, highly concurrent matrices", () => {
let totalLanes = 0;
let totalPartitions = 0;
for (const node of graph.nodes.values()) {
for (const lane of node.lanes) {
totalLanes += 1;
const kind = lane === "contract" ? "unit" : lane.startsWith("ct-") ? "ct" : "e2e";
const partitions = node.partitions[lane];
totalPartitions += partitions;
expect(partitions, `${node.id}/${lane}`).toBeLessThanOrEqual(256);
expect(
Math.ceil(node.testInventory[lane].expectedFiles / partitions),
`${node.id}/${lane}`
).toBeLessThanOrEqual(partitionFileLimits[kind]);
}
}
expect(totalPartitions).toBeGreaterThan(totalLanes);
});
it("keeps planner and generated job identifiers in sync", () => {
const plan = planComponentSelection({
graph,
parsedCatalog,
profile: "pr",
changedFiles: ["src/components/displays/buttons/DatePeriodSelector.vue"],
});
const generated = generateStaticComponentJobs(graph);
for (const job of plan.selectedJobs) {
expect(generated).toContain(` ${job.jobId}:`);
}
});
});