Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12a2d73b32 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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)}.`);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
+34
-6
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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}:`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user