Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12a2d73b32 | ||
|
|
6dc27a355f | ||
|
|
eeec725f08 | ||
|
|
97df3193e3 | ||
|
|
ed2d67934c |
@@ -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
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 632 B After Width: | Height: | Size: 861 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 13 KiB |
@@ -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",
|
||||
@@ -74,6 +80,7 @@
|
||||
"mobile:android:play-upload": "node scripts/mobile/upload-google-play.mjs",
|
||||
"mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check",
|
||||
"mobile:ios:device": "node scripts/mobile/ios-device.mjs",
|
||||
"mobile:ios:screenshots": "node scripts/playstore/generate-graphics.mjs --app-store-screenshots",
|
||||
"mobile:ios:storefront:check": "node scripts/mobile/validate-app-store.mjs",
|
||||
"mobile:ios:storefront:check-strict": "node scripts/mobile/validate-app-store.mjs --strict",
|
||||
"playstore:graphics": "node scripts/playstore/generate-graphics.mjs"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 57 KiB |
@@ -5,10 +5,9 @@ import { fileURLToPath } from "node:url";
|
||||
import Jimp from "jimp";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const sourcePath = path.join(
|
||||
projectRoot,
|
||||
"ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png",
|
||||
);
|
||||
const sourcePath = path.join(projectRoot, "public/favicons/web-app-manifest-512x512.png");
|
||||
const iosIconDirectory = path.join(projectRoot, "ios/App/App/Assets.xcassets/AppIcon.appiconset");
|
||||
const iosContentsPath = path.join(iosIconDirectory, "Contents.json");
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
const launcherBackground = "#FFFFFF";
|
||||
|
||||
@@ -41,6 +40,7 @@ for (const [density, scale] of Object.entries(densityScale)) {
|
||||
{
|
||||
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher_foreground.png`,
|
||||
size: foregroundSize,
|
||||
safeZone: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -84,8 +84,19 @@ async function writeIfChanged(relativePath, content) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function renderPng(sourceImage, size) {
|
||||
const image = sourceImage.clone().resize(size, size, Jimp.RESIZE_BICUBIC);
|
||||
async function renderPng(sourceImage, size, safeZone = false) {
|
||||
const image = new Jimp(size, size, launcherBackground);
|
||||
const artworkSize = safeZone ? Math.round(size * 0.72) : size;
|
||||
const artwork = sourceImage.clone();
|
||||
if (artwork.bitmap.width !== artworkSize || artwork.bitmap.height !== artworkSize) {
|
||||
artwork.resize(artworkSize, artworkSize, Jimp.RESIZE_BICUBIC);
|
||||
}
|
||||
const offset = Math.round((size - artworkSize) / 2);
|
||||
image.composite(artwork, offset, offset);
|
||||
image.scan(0, 0, size, size, (_x, _y, index) => {
|
||||
image.bitmap.data[index + 3] = 255;
|
||||
});
|
||||
image.colorType(2);
|
||||
return image.getBufferAsync(Jimp.MIME_PNG);
|
||||
}
|
||||
|
||||
@@ -93,14 +104,24 @@ async function main() {
|
||||
const sourceBuffer = await fs.readFile(sourcePath);
|
||||
const sourceImage = await Jimp.read(sourceBuffer);
|
||||
|
||||
if (sourceImage.bitmap.width !== 1024 || sourceImage.bitmap.height !== 1024) {
|
||||
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 1024x1024 PNG.`);
|
||||
if (sourceImage.bitmap.width !== 512 || sourceImage.bitmap.height !== 512) {
|
||||
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 512x512 PNG.`);
|
||||
}
|
||||
|
||||
const iosContents = JSON.parse(await fs.readFile(iosContentsPath, "utf8"));
|
||||
const iosTargets = iosContents.images.map(({ filename, scale, size }) => ({
|
||||
relativePath: path.relative(projectRoot, path.join(iosIconDirectory, filename)),
|
||||
size: Math.round(Number.parseFloat(size) * Number.parseFloat(scale)),
|
||||
}));
|
||||
iosTargets.push({
|
||||
relativePath: path.relative(projectRoot, path.join(iosIconDirectory, "AppIcon-512@2x.png")),
|
||||
size: 1024,
|
||||
});
|
||||
const imageTargets = [...targets, ...iosTargets];
|
||||
const changed = [];
|
||||
|
||||
for (const target of targets) {
|
||||
const buffer = await renderPng(sourceImage, target.size);
|
||||
for (const target of imageTargets) {
|
||||
const buffer = await renderPng(sourceImage, target.size, target.safeZone);
|
||||
if (await writeIfChanged(target.relativePath, buffer)) {
|
||||
changed.push(target.relativePath);
|
||||
}
|
||||
@@ -113,12 +134,12 @@ async function main() {
|
||||
}
|
||||
|
||||
if (changed.length === 0) {
|
||||
console.log(`Android icon assets are current (${targets.length + xmlTargets.length} files).`);
|
||||
console.log(`Mobile icon assets are current (${imageTargets.length + xmlTargets.length} files).`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
console.error("Android icon assets are out of date:");
|
||||
console.error("Mobile icon assets are out of date:");
|
||||
for (const relativePath of changed) {
|
||||
console.error(`- ${relativePath}`);
|
||||
}
|
||||
@@ -127,7 +148,7 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Updated ${changed.length} Android icon asset${changed.length === 1 ? "" : "s"}.`);
|
||||
console.log(`Updated ${changed.length} mobile icon asset${changed.length === 1 ? "" : "s"}.`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -15,10 +15,12 @@ const workspaceRoot = fsSync.existsSync("/home/jeppe/pleno-workspace/frontend/pa
|
||||
? "/home/jeppe/pleno-workspace"
|
||||
: path.resolve(projectRoot, "..");
|
||||
const outputRoot = path.join(workspaceRoot, "playstoregraphics");
|
||||
const appStoreScreenshotRoot = path.join(projectRoot, "fastlane/screenshots/da-DK");
|
||||
const baseUrl = process.env.PLAYSTORE_GRAPHICS_BASE_URL || "http://127.0.0.1:5179";
|
||||
const devPort = new URL(baseUrl).port || "5179";
|
||||
const devHost = new URL(baseUrl).hostname || "127.0.0.1";
|
||||
const generatedAt = new Date().toISOString();
|
||||
const appStoreScreenshotsOnly = process.argv.includes("--app-store-screenshots");
|
||||
|
||||
const disallowedRoutePatterns = [
|
||||
/^\/admin(?:\/|$)/i,
|
||||
@@ -81,6 +83,29 @@ const deviceProfiles = [
|
||||
},
|
||||
];
|
||||
|
||||
const appStoreDeviceProfiles = [
|
||||
{
|
||||
key: "iphone-6.9",
|
||||
outputRoot: appStoreScreenshotRoot,
|
||||
fileNamePrefix: "iphone-6.9-",
|
||||
viewport: { width: 440, height: 956 },
|
||||
deviceScaleFactor: 3,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
expected: { width: 1320, height: 2868 },
|
||||
},
|
||||
{
|
||||
key: "ipad-13",
|
||||
outputRoot: appStoreScreenshotRoot,
|
||||
fileNamePrefix: "ipad-13-",
|
||||
viewport: { width: 768, height: 1024 },
|
||||
deviceScaleFactor: 2.6875,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
expected: { width: 2064, height: 2752 },
|
||||
},
|
||||
];
|
||||
|
||||
const screenshotScenes = [
|
||||
{
|
||||
key: "dashboard",
|
||||
@@ -197,7 +222,7 @@ const screenshotScenes = [
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
if (device?.key === "phone") {
|
||||
if (device?.key === "phone" || device?.key === "iphone-6.9") {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const nextButton = document.querySelector('[data-testid="booking-mobile-next"]');
|
||||
@@ -362,7 +387,7 @@ const products = [
|
||||
const demoPosFixture = createPosFixture({
|
||||
departments: [
|
||||
{ id: 1, name: "Roskilde", exclude_from_invoicing: false },
|
||||
{ id: 12, name: "Taastrup", exclude_from_invoicing: false },
|
||||
{ id: 12, name: "Taastrup", exclude_from_invoicing: false, bookingsystem_time_based_enabled: true },
|
||||
],
|
||||
products,
|
||||
departmentCategories: [
|
||||
@@ -726,7 +751,10 @@ const demoSelfServe = {
|
||||
};
|
||||
|
||||
async function ensureDirectories() {
|
||||
await Promise.all(requiredDirectories.map((directory) => fs.mkdir(path.join(outputRoot, directory), { recursive: true })));
|
||||
await Promise.all([
|
||||
...requiredDirectories.map((directory) => fs.mkdir(path.join(outputRoot, directory), { recursive: true })),
|
||||
fs.mkdir(appStoreScreenshotRoot, { recursive: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
function isDisallowedRoute(routePath) {
|
||||
@@ -857,7 +885,7 @@ async function installCommonPageState(context, page) {
|
||||
async function setupMockApi(page, mode) {
|
||||
const baseOptions = {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
permissions: ["user", "add_own_department_selfserve_vehicle_conditions"],
|
||||
sessionData: demoSession,
|
||||
edgeGateways: false,
|
||||
workerVersion: "playstore-graphics",
|
||||
@@ -977,7 +1005,12 @@ async function captureScene(browser, device, scene) {
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(350);
|
||||
|
||||
const outputPath = path.join(outputRoot, device.screenshotDir, scene.fileName);
|
||||
const captureRoot = device.outputRoot || outputRoot;
|
||||
const outputPath = path.join(
|
||||
captureRoot,
|
||||
device.screenshotDir || "",
|
||||
`${device.fileNamePrefix || ""}${scene.fileName}`,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: outputPath,
|
||||
type: "jpeg",
|
||||
@@ -993,7 +1026,8 @@ async function captureScene(browser, device, scene) {
|
||||
type: "screenshot",
|
||||
scene: scene.key,
|
||||
route: currentPath,
|
||||
path: path.relative(outputRoot, outputPath),
|
||||
path: path.relative(captureRoot, outputPath),
|
||||
...(device.outputRoot ? { absolutePath: outputPath } : {}),
|
||||
altText: scene.altText,
|
||||
expected: device.expected,
|
||||
};
|
||||
@@ -1400,7 +1434,7 @@ async function createFeatureGraphicVariants(browser) {
|
||||
}
|
||||
|
||||
async function validateAsset(entry) {
|
||||
const absolutePath = path.join(outputRoot, entry.path);
|
||||
const absolutePath = entry.absolutePath || path.join(outputRoot, entry.path);
|
||||
const metadata = await readImageMetadata(absolutePath);
|
||||
if (metadata.width !== entry.expected.width || metadata.height !== entry.expected.height) {
|
||||
throw new Error(
|
||||
@@ -1472,17 +1506,23 @@ async function run() {
|
||||
const entries = [];
|
||||
|
||||
try {
|
||||
entries.push(await copyAndValidateIcon());
|
||||
|
||||
for (const device of deviceProfiles) {
|
||||
const captureDevices = appStoreScreenshotsOnly ? appStoreDeviceProfiles : deviceProfiles;
|
||||
for (const device of captureDevices) {
|
||||
for (const scene of screenshotScenes) {
|
||||
entries.push(await captureScene(browser, device, scene));
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(await createFeatureGraphic(browser));
|
||||
entries.push(...(await createFeatureGraphicVariants(browser)));
|
||||
await writeMetadata(entries);
|
||||
if (appStoreScreenshotsOnly) {
|
||||
for (const entry of entries) {
|
||||
await validateAsset(entry);
|
||||
}
|
||||
} else {
|
||||
entries.unshift(await copyAndValidateIcon());
|
||||
entries.push(await createFeatureGraphic(browser));
|
||||
entries.push(...(await createFeatureGraphicVariants(browser)));
|
||||
await writeMetadata(entries);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (server) {
|
||||
@@ -1494,7 +1534,11 @@ async function run() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Generated ${entries.length} Play Store graphics in ${outputRoot}`);
|
||||
if (appStoreScreenshotsOnly) {
|
||||
console.log(`Generated ${entries.length} App Store screenshots in ${appStoreScreenshotRoot}`);
|
||||
} else {
|
||||
console.log(`Generated ${entries.length} Play Store graphics in ${outputRoot}`);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -1268,11 +1268,11 @@
|
||||
"help_booking": "Buchung @:{'words.generated.und'} Aenderung @:{'words.generated.von'} Waschzeiten.",
|
||||
"help_self_wash": "Start @:{'words.generated.und'} Abschluss @:{'words.generated.von'} Selbstbedienungswaeschen.",
|
||||
"help_payments": "Bestellungen, @:{'words.generated.rechnungen'} @:{'words.generated.und'} Kartenzahlungen.",
|
||||
"help_account": "Login, Zugriff und Fahrzeuge in Ihrem Konto.",
|
||||
"help_deletion": "Hilfe zu Datenschutz, personenbezogenen Daten und Kontoloeschung.",
|
||||
"help_account": "Login, Zugriff @:{'words.generated.und'} Fahrzeuge in Ihrem Konto.",
|
||||
"help_deletion": "Hilfe @:{'words.generated.zu'} Datenschutz, personenbezogenen @:{'words.generated.daten'} @:{'words.generated.und'} Kontoloeschung.",
|
||||
"privacy_prefix": "Lesen Sie auch unsere",
|
||||
"privacy_link": "Datenschutzerklaerung",
|
||||
"privacy_suffix": "@:{'words.generated.fuer'} @:{'words.generated.informationen'} zu Daten @:{'words.generated.und'} Rechten."
|
||||
"privacy_suffix": "@:{'words.generated.fuer'} @:{'words.generated.informationen'} @:{'words.generated.zu'} @:{'words.generated.daten'} @:{'words.generated.und'} Rechten."
|
||||
},
|
||||
"common": {
|
||||
"step_of": "@:{'words.generated.schritt'} {current} @:{'words.generated.von'} {total}",
|
||||
@@ -5292,6 +5292,63 @@
|
||||
"wash_certificate": "@:{'words.generated.waschzertifikat'}",
|
||||
"wash_type": "Tvättyp"
|
||||
},
|
||||
"privacy_policy": {
|
||||
"changes_body": "We may update this policy when the service, providers, or legal requirements change. The current version and date are published here.",
|
||||
"changes_title": "11. Changes",
|
||||
"contact_body": "Contact us about privacy, rights, or account deletion at",
|
||||
"contact_title": "12. Contact",
|
||||
"controller_intro": "Truck Wash ApS is the data controller for processing in the Truck Wash customer portal and app.",
|
||||
"controller_title": "1. Data controller",
|
||||
"data": {
|
||||
"account": "Account and contact information, including name, company, address, email, phone number, customer number, username, and security information.",
|
||||
"content": "Content you submit, including support messages, photos, attachments, and information included in error reports.",
|
||||
"service": "Vehicles and registration plates, bookings, wash history, orders, invoices, subscriptions, and payment or transaction status.",
|
||||
"technical": "Session, device, browser, network, security, and diagnostic information when needed to operate, troubleshoot, and protect the service."
|
||||
},
|
||||
"data_intro": "Depending on the features and access you use, we process:",
|
||||
"data_title": "3. Information we process",
|
||||
"deletion_body": "When signed in, you can start account deletion under Profile → Delete account. Your identity is confirmed using your password and two-factor authentication when enabled. Once accepted, access and active sessions are revoked immediately, and personal profile information is deleted or anonymized through our deletion process.",
|
||||
"deletion_retained": "Invoices, payment documentation, orders, connected wash history, and limited security or audit logs may be retained for the period required by accounting, security, or other applicable laws. They are not used for marketing after the deletion request.",
|
||||
"deletion_title": "7. Account deletion",
|
||||
"meta_description": "Privacy policy for the Truck Wash customer portal and iOS app.",
|
||||
"permissions": "The camera is used only when you choose to scan QR codes or registration plates. Location is used while the app is open to find or confirm the nearest Truck Wash department. Access can be denied in the device settings.",
|
||||
"processors": {
|
||||
"economic": "e-conomic for customer, order, invoice, and accounting processing.",
|
||||
"hosting": "Operations, hosting, communications, and security providers that process information on our behalf.",
|
||||
"microsoft": "Microsoft when you voluntarily connect Microsoft calendar or sign-in.",
|
||||
"recaptcha": "Google reCAPTCHA when risk assessment or protection against automated abuse is activated.",
|
||||
"stripe": "Stripe when a department or payment uses Stripe payment processing."
|
||||
},
|
||||
"purposes_body": "We process information to create and secure accounts, provide booking and wash services, manage vehicles, orders and invoices, process payments, send necessary service messages, provide support, prevent abuse, and comply with accounting and other legal duties. The legal bases are contract performance, legitimate interests, legal obligations, and consent where required.",
|
||||
"purposes_title": "4. Purposes and legal bases",
|
||||
"retention_body": "Profile and service data are retained while the account or customer relationship is active and then only as long as necessary for documented purposes, disputes, and applicable law. Security logs are kept for a limited period. Legally required accounting and transaction data may have a longer retention period.",
|
||||
"retention_title": "6. Retention",
|
||||
"rights": {
|
||||
"access": "Access to the personal information we process about you.",
|
||||
"complaint": "Complain to the Danish Data Protection Agency or another competent supervisory authority.",
|
||||
"correction": "Correction of inaccurate or incomplete information.",
|
||||
"erasure": "Erasure when processing is no longer necessary or legally required.",
|
||||
"objection": "Object to processing based on legitimate interests.",
|
||||
"portability": "Data portability where the rules provide that right.",
|
||||
"restriction": "Restriction of processing in the situations provided by law."
|
||||
},
|
||||
"rights_intro": "Depending on the circumstances, you have the right to:",
|
||||
"rights_title": "10. Your rights",
|
||||
"scope_body": "This policy applies to truckwash.io, the Truck Wash customer portal, and the native iOS app. It covers customers and drivers as well as administrative functions to which a user has been granted access.",
|
||||
"scope_title": "2. Scope",
|
||||
"security_body": "We use access control, encrypted transport, session protection, logging, and organizational procedures to protect information. No service can be guaranteed completely secure; contact us immediately if you suspect misuse.",
|
||||
"security_title": "8. Security",
|
||||
"sharing_intro": "We disclose information only when necessary for the service, our agreement, security, or legal requirements. Relevant categories of processors and independent controllers include:",
|
||||
"sharing_limit": "We do not sell personal information or use it for third-party advertising or tracking across other companies' apps and websites.",
|
||||
"sharing_title": "5. Providers and sharing",
|
||||
"support_link": "Go to support",
|
||||
"title": "Privacy policy",
|
||||
"tracking_body": "The portal uses necessary local storage and session mechanisms for sign-in, security, language, and functionality. External sign-in, payment, or abuse-prevention services may process technical information when their feature is used. We do not use advertising cookies or marketing analytics in the app.",
|
||||
"tracking_title": "Cookies, local storage, and tracking",
|
||||
"transfers_body": "If a provider processes information outside the EU/EEA, we require a valid transfer mechanism, such as an adequacy decision or EU Standard Contractual Clauses, together with appropriate supplementary safeguards.",
|
||||
"transfers_title": "9. International transfers",
|
||||
"updated": "Last updated: July 20, 2026"
|
||||
},
|
||||
"products": {
|
||||
"admin": {
|
||||
"subtitle": "@.capitalize:{'words.generated.hier'} @:{'words.generated.k'}?@:{'words.generated.nnen'} @.capitalize:{'words.generated.sie'} @:{'words.generated.produkte'} @:{'words.generated.verwalten'}",
|
||||
|
||||
@@ -990,9 +990,9 @@
|
||||
"help_self_wash": "Starting @:{'words.generated.and'} ending @:{'words.generated.self'}-@:{'words.generated.service'} @:{'words.generated.wash'} sessions.",
|
||||
"help_payments": "@.capitalize:{'words.generated.orders'}, @:{'words.generated.invoices'}, @:{'words.generated.and'} @:{'words.generated.card'} @:{'words.generated.payments'}.",
|
||||
"help_account": "Login, @:{'words.generated.access'}, @:{'words.generated.and'} @:{'words.generated.vehicles'} on your @:{'words.generated.account'}.",
|
||||
"help_deletion": "Help with privacy, personal information, and account deletion.",
|
||||
"help_deletion": "Help with @:{'words.generated.privacy'}, personal information, and account deletion.",
|
||||
"privacy_prefix": "Also read our",
|
||||
"privacy_link": "privacy policy",
|
||||
"privacy_link": "@:{'words.generated.privacy'} policy",
|
||||
"privacy_suffix": "@:{'words.generated.for'} @:{'words.generated.details'} @:{'words.generated.about'} @:{'words.generated.data'} @:{'words.generated.and'} @:{'words.generated.rights'}."
|
||||
},
|
||||
"common": {
|
||||
@@ -6532,7 +6532,7 @@
|
||||
"password_label": "Password",
|
||||
"password_required": "Enter your password.",
|
||||
"password_title": "Confirm your identity",
|
||||
"privacy_link": "Read the privacy policy and deletion information",
|
||||
"privacy_link": "Read the @:{'words.generated.privacy'} policy and deletion information",
|
||||
"retained": {
|
||||
"audit_logs": "Audit trails that must be retained for security or legal reasons",
|
||||
"customer_reference": "A limited customer-account reference where historical records require it",
|
||||
|
||||
@@ -5295,6 +5295,63 @@
|
||||
"wash_certificate": "@.capitalize:{'words.generated.vaskebevis'}",
|
||||
"wash_type": "@.capitalize:{'words.generated.vasketype'}"
|
||||
},
|
||||
"privacy_policy": {
|
||||
"changes_body": "We may update this policy when the service, providers, or legal requirements change. The current version and date are published here.",
|
||||
"changes_title": "11. Changes",
|
||||
"contact_body": "Contact us about privacy, rights, or account deletion at",
|
||||
"contact_title": "12. Contact",
|
||||
"controller_intro": "Truck Wash ApS is the data controller for processing in the Truck Wash customer portal and app.",
|
||||
"controller_title": "1. Data controller",
|
||||
"data": {
|
||||
"account": "Account and contact information, including name, company, address, email, phone number, customer number, username, and security information.",
|
||||
"content": "Content you submit, including support messages, photos, attachments, and information included in error reports.",
|
||||
"service": "Vehicles and registration plates, bookings, wash history, orders, invoices, subscriptions, and payment or transaction status.",
|
||||
"technical": "Session, device, browser, network, security, and diagnostic information when needed to operate, troubleshoot, and protect the service."
|
||||
},
|
||||
"data_intro": "Depending on the features and access you use, we process:",
|
||||
"data_title": "3. Information we process",
|
||||
"deletion_body": "When signed in, you can start account deletion under Profile → Delete account. Your identity is confirmed using your password and two-factor authentication when enabled. Once accepted, access and active sessions are revoked immediately, and personal profile information is deleted or anonymized through our deletion process.",
|
||||
"deletion_retained": "Invoices, payment documentation, orders, connected wash history, and limited security or audit logs may be retained for the period required by accounting, security, or other applicable laws. They are not used for marketing after the deletion request.",
|
||||
"deletion_title": "7. Account deletion",
|
||||
"meta_description": "Privacy policy for the Truck Wash customer portal and iOS app.",
|
||||
"permissions": "The camera is used only when you choose to scan QR codes or registration plates. Location is used while the app is open to find or confirm the nearest Truck Wash department. Access can be denied in the device settings.",
|
||||
"processors": {
|
||||
"economic": "e-conomic for customer, order, invoice, and accounting processing.",
|
||||
"hosting": "Operations, hosting, communications, and security providers that process information on our behalf.",
|
||||
"microsoft": "Microsoft when you voluntarily connect Microsoft calendar or sign-in.",
|
||||
"recaptcha": "Google reCAPTCHA when risk assessment or protection against automated abuse is activated.",
|
||||
"stripe": "Stripe when a department or payment uses Stripe payment processing."
|
||||
},
|
||||
"purposes_body": "We process information to create and secure accounts, provide booking and wash services, manage vehicles, orders and invoices, process payments, send necessary service messages, provide support, prevent abuse, and comply with accounting and other legal duties. The legal bases are contract performance, legitimate interests, legal obligations, and consent where required.",
|
||||
"purposes_title": "4. Purposes and legal bases",
|
||||
"retention_body": "Profile and service data are retained while the account or customer relationship is active and then only as long as necessary for documented purposes, disputes, and applicable law. Security logs are kept for a limited period. Legally required accounting and transaction data may have a longer retention period.",
|
||||
"retention_title": "6. Retention",
|
||||
"rights": {
|
||||
"access": "Access to the personal information we process about you.",
|
||||
"complaint": "Complain to the Danish Data Protection Agency or another competent supervisory authority.",
|
||||
"correction": "Correction of inaccurate or incomplete information.",
|
||||
"erasure": "Erasure when processing is no longer necessary or legally required.",
|
||||
"objection": "Object to processing based on legitimate interests.",
|
||||
"portability": "Data portability where the rules provide that right.",
|
||||
"restriction": "Restriction of processing in the situations provided by law."
|
||||
},
|
||||
"rights_intro": "Depending on the circumstances, you have the right to:",
|
||||
"rights_title": "10. Your rights",
|
||||
"scope_body": "This policy applies to truckwash.io, the Truck Wash customer portal, and the native iOS app. It covers customers and drivers as well as administrative functions to which a user has been granted access.",
|
||||
"scope_title": "2. Scope",
|
||||
"security_body": "We use access control, encrypted transport, session protection, logging, and organizational procedures to protect information. No service can be guaranteed completely secure; contact us immediately if you suspect misuse.",
|
||||
"security_title": "8. Security",
|
||||
"sharing_intro": "We disclose information only when necessary for the service, our agreement, security, or legal requirements. Relevant categories of processors and independent controllers include:",
|
||||
"sharing_limit": "We do not sell personal information or use it for third-party advertising or tracking across other companies' apps and websites.",
|
||||
"sharing_title": "5. Providers and sharing",
|
||||
"support_link": "Go to support",
|
||||
"title": "Privacy policy",
|
||||
"tracking_body": "The portal uses necessary local storage and session mechanisms for sign-in, security, language, and functionality. External sign-in, payment, or abuse-prevention services may process technical information when their feature is used. We do not use advertising cookies or marketing analytics in the app.",
|
||||
"tracking_title": "Cookies, local storage, and tracking",
|
||||
"transfers_body": "If a provider processes information outside the EU/EEA, we require a valid transfer mechanism, such as an adequacy decision or EU Standard Contractual Clauses, together with appropriate supplementary safeguards.",
|
||||
"transfers_title": "9. International transfers",
|
||||
"updated": "Last updated: July 20, 2026"
|
||||
},
|
||||
"products": {
|
||||
"admin": {
|
||||
"subtitle": "@.capitalize:{'words.generated.her'} @:{'words.generated.kan'} @:{'words.generated.du'} @:{'words.generated.handtere'} @:{'words.generated.produkter'}",
|
||||
|
||||
@@ -5345,6 +5345,63 @@
|
||||
"wash_certificate": "@.capitalize:{'words.generated.tvattcertifikat'}",
|
||||
"wash_type": "Tvättyp"
|
||||
},
|
||||
"privacy_policy": {
|
||||
"changes_body": "We may update this policy when the service, providers, or legal requirements change. The current version and date are published here.",
|
||||
"changes_title": "11. Changes",
|
||||
"contact_body": "Contact us about privacy, rights, or account deletion at",
|
||||
"contact_title": "12. Contact",
|
||||
"controller_intro": "Truck Wash ApS is the data controller for processing in the Truck Wash customer portal and app.",
|
||||
"controller_title": "1. Data controller",
|
||||
"data": {
|
||||
"account": "Account and contact information, including name, company, address, email, phone number, customer number, username, and security information.",
|
||||
"content": "Content you submit, including support messages, photos, attachments, and information included in error reports.",
|
||||
"service": "Vehicles and registration plates, bookings, wash history, orders, invoices, subscriptions, and payment or transaction status.",
|
||||
"technical": "Session, device, browser, network, security, and diagnostic information when needed to operate, troubleshoot, and protect the service."
|
||||
},
|
||||
"data_intro": "Depending on the features and access you use, we process:",
|
||||
"data_title": "3. Information we process",
|
||||
"deletion_body": "When signed in, you can start account deletion under Profile → Delete account. Your identity is confirmed using your password and two-factor authentication when enabled. Once accepted, access and active sessions are revoked immediately, and personal profile information is deleted or anonymized through our deletion process.",
|
||||
"deletion_retained": "Invoices, payment documentation, orders, connected wash history, and limited security or audit logs may be retained for the period required by accounting, security, or other applicable laws. They are not used for marketing after the deletion request.",
|
||||
"deletion_title": "7. Account deletion",
|
||||
"meta_description": "Privacy policy for the Truck Wash customer portal and iOS app.",
|
||||
"permissions": "The camera is used only when you choose to scan QR codes or registration plates. Location is used while the app is open to find or confirm the nearest Truck Wash department. Access can be denied in the device settings.",
|
||||
"processors": {
|
||||
"economic": "e-conomic for customer, order, invoice, and accounting processing.",
|
||||
"hosting": "Operations, hosting, communications, and security providers that process information on our behalf.",
|
||||
"microsoft": "Microsoft when you voluntarily connect Microsoft calendar or sign-in.",
|
||||
"recaptcha": "Google reCAPTCHA when risk assessment or protection against automated abuse is activated.",
|
||||
"stripe": "Stripe when a department or payment uses Stripe payment processing."
|
||||
},
|
||||
"purposes_body": "We process information to create and secure accounts, provide booking and wash services, manage vehicles, orders and invoices, process payments, send necessary service messages, provide support, prevent abuse, and comply with accounting and other legal duties. The legal bases are contract performance, legitimate interests, legal obligations, and consent where required.",
|
||||
"purposes_title": "4. Purposes and legal bases",
|
||||
"retention_body": "Profile and service data are retained while the account or customer relationship is active and then only as long as necessary for documented purposes, disputes, and applicable law. Security logs are kept for a limited period. Legally required accounting and transaction data may have a longer retention period.",
|
||||
"retention_title": "6. Retention",
|
||||
"rights": {
|
||||
"access": "Access to the personal information we process about you.",
|
||||
"complaint": "Complain to the Danish Data Protection Agency or another competent supervisory authority.",
|
||||
"correction": "Correction of inaccurate or incomplete information.",
|
||||
"erasure": "Erasure when processing is no longer necessary or legally required.",
|
||||
"objection": "Object to processing based on legitimate interests.",
|
||||
"portability": "Data portability where the rules provide that right.",
|
||||
"restriction": "Restriction of processing in the situations provided by law."
|
||||
},
|
||||
"rights_intro": "Depending on the circumstances, you have the right to:",
|
||||
"rights_title": "10. Your rights",
|
||||
"scope_body": "This policy applies to truckwash.io, the Truck Wash customer portal, and the native iOS app. It covers customers and drivers as well as administrative functions to which a user has been granted access.",
|
||||
"scope_title": "2. Scope",
|
||||
"security_body": "We use access control, encrypted transport, session protection, logging, and organizational procedures to protect information. No service can be guaranteed completely secure; contact us immediately if you suspect misuse.",
|
||||
"security_title": "8. Security",
|
||||
"sharing_intro": "We disclose information only when necessary for the service, our agreement, security, or legal requirements. Relevant categories of processors and independent controllers include:",
|
||||
"sharing_limit": "We do not sell personal information or use it for third-party advertising or tracking across other companies' apps and websites.",
|
||||
"sharing_title": "5. Providers and sharing",
|
||||
"support_link": "Go to support",
|
||||
"title": "Privacy policy",
|
||||
"tracking_body": "The portal uses necessary local storage and session mechanisms for sign-in, security, language, and functionality. External sign-in, payment, or abuse-prevention services may process technical information when their feature is used. We do not use advertising cookies or marketing analytics in the app.",
|
||||
"tracking_title": "Cookies, local storage, and tracking",
|
||||
"transfers_body": "If a provider processes information outside the EU/EEA, we require a valid transfer mechanism, such as an adequacy decision or EU Standard Contractual Clauses, together with appropriate supplementary safeguards.",
|
||||
"transfers_title": "9. International transfers",
|
||||
"updated": "Last updated: July 20, 2026"
|
||||
},
|
||||
"products": {
|
||||
"admin": {
|
||||
"subtitle": "@:{'words.generated.her'} @:{'words.generated.kan'} @:{'words.generated.du'} @:{'words.generated.handtere'} @:{'words.generated.produkter'}",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"compat": {
|
||||
"privacy_policy": {
|
||||
"changes_body": "We may update this policy when the service, providers, or legal requirements change. The current version and date are published here.",
|
||||
"changes_title": "11. Changes",
|
||||
"contact_body": "Contact us about privacy, rights, or account deletion at",
|
||||
"contact_title": "12. Contact",
|
||||
"controller_intro": "Truck Wash ApS is the data controller for processing in the Truck Wash customer portal and app.",
|
||||
"controller_title": "1. Data controller",
|
||||
"data": {
|
||||
"account": "Account and contact information, including name, company, address, email, phone number, customer number, username, and security information.",
|
||||
"content": "Content you submit, including support messages, photos, attachments, and information included in error reports.",
|
||||
"service": "Vehicles and registration plates, bookings, wash history, orders, invoices, subscriptions, and payment or transaction status.",
|
||||
"technical": "Session, device, browser, network, security, and diagnostic information when needed to operate, troubleshoot, and protect the service."
|
||||
},
|
||||
"data_intro": "Depending on the features and access you use, we process:",
|
||||
"data_title": "3. Information we process",
|
||||
"deletion_body": "When signed in, you can start account deletion under Profile → Delete account. Your identity is confirmed using your password and two-factor authentication when enabled. Once accepted, access and active sessions are revoked immediately, and personal profile information is deleted or anonymized through our deletion process.",
|
||||
"deletion_retained": "Invoices, payment documentation, orders, connected wash history, and limited security or audit logs may be retained for the period required by accounting, security, or other applicable laws. They are not used for marketing after the deletion request.",
|
||||
"deletion_title": "7. Account deletion",
|
||||
"meta_description": "Privacy policy for the Truck Wash customer portal and iOS app.",
|
||||
"permissions": "The camera is used only when you choose to scan QR codes or registration plates. Location is used while the app is open to find or confirm the nearest Truck Wash department. Access can be denied in the device settings.",
|
||||
"processors": {
|
||||
"economic": "e-conomic for customer, order, invoice, and accounting processing.",
|
||||
"hosting": "Operations, hosting, communications, and security providers that process information on our behalf.",
|
||||
"microsoft": "Microsoft when you voluntarily connect Microsoft calendar or sign-in.",
|
||||
"recaptcha": "Google reCAPTCHA when risk assessment or protection against automated abuse is activated.",
|
||||
"stripe": "Stripe when a department or payment uses Stripe payment processing."
|
||||
},
|
||||
"purposes_body": "We process information to create and secure accounts, provide booking and wash services, manage vehicles, orders and invoices, process payments, send necessary service messages, provide support, prevent abuse, and comply with accounting and other legal duties. The legal bases are contract performance, legitimate interests, legal obligations, and consent where required.",
|
||||
"purposes_title": "4. Purposes and legal bases",
|
||||
"retention_body": "Profile and service data are retained while the account or customer relationship is active and then only as long as necessary for documented purposes, disputes, and applicable law. Security logs are kept for a limited period. Legally required accounting and transaction data may have a longer retention period.",
|
||||
"retention_title": "6. Retention",
|
||||
"rights": {
|
||||
"access": "Access to the personal information we process about you.",
|
||||
"complaint": "Complain to the Danish Data Protection Agency or another competent supervisory authority.",
|
||||
"correction": "Correction of inaccurate or incomplete information.",
|
||||
"erasure": "Erasure when processing is no longer necessary or legally required.",
|
||||
"objection": "Object to processing based on legitimate interests.",
|
||||
"portability": "Data portability where the rules provide that right.",
|
||||
"restriction": "Restriction of processing in the situations provided by law."
|
||||
},
|
||||
"rights_intro": "Depending on the circumstances, you have the right to:",
|
||||
"rights_title": "10. Your rights",
|
||||
"scope_body": "This policy applies to truckwash.io, the Truck Wash customer portal, and the native iOS app. It covers customers and drivers as well as administrative functions to which a user has been granted access.",
|
||||
"scope_title": "2. Scope",
|
||||
"security_body": "We use access control, encrypted transport, session protection, logging, and organizational procedures to protect information. No service can be guaranteed completely secure; contact us immediately if you suspect misuse.",
|
||||
"security_title": "8. Security",
|
||||
"sharing_intro": "We disclose information only when necessary for the service, our agreement, security, or legal requirements. Relevant categories of processors and independent controllers include:",
|
||||
"sharing_limit": "We do not sell personal information or use it for third-party advertising or tracking across other companies' apps and websites.",
|
||||
"sharing_title": "5. Providers and sharing",
|
||||
"support_link": "Go to support",
|
||||
"title": "Privacy policy",
|
||||
"tracking_body": "The portal uses necessary local storage and session mechanisms for sign-in, security, language, and functionality. External sign-in, payment, or abuse-prevention services may process technical information when their feature is used. We do not use advertising cookies or marketing analytics in the app.",
|
||||
"tracking_title": "Cookies, local storage, and tracking",
|
||||
"transfers_body": "If a provider processes information outside the EU/EEA, we require a valid transfer mechanism, such as an adequacy decision or EU Standard Contractual Clauses, together with appropriate supplementary safeguards.",
|
||||
"transfers_title": "9. International transfers",
|
||||
"updated": "Last updated: July 20, 2026"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,10 @@
|
||||
"help_booking": "Buchung @:{'terms.glossary.und'} Aenderung @:{'terms.glossary.von'} Waschzeiten.",
|
||||
"help_self_wash": "Start @:{'terms.glossary.und'} Abschluss @:{'terms.glossary.von'} Selbstbedienungswaeschen.",
|
||||
"help_payments": "Bestellungen, @:{'terms.glossary.rechnungen'} @:{'terms.glossary.und'} Kartenzahlungen.",
|
||||
"help_account": "Login, Zugriff und Fahrzeuge in Ihrem Konto.",
|
||||
"help_deletion": "Hilfe zu Datenschutz, personenbezogenen Daten und Kontoloeschung.",
|
||||
"help_account": "Login, Zugriff @:{'terms.glossary.und'} Fahrzeuge in Ihrem Konto.",
|
||||
"help_deletion": "Hilfe @:{'terms.glossary.zu'} Datenschutz, personenbezogenen @:{'terms.glossary.daten'} @:{'terms.glossary.und'} Kontoloeschung.",
|
||||
"privacy_prefix": "Lesen Sie auch unsere",
|
||||
"privacy_link": "Datenschutzerklaerung",
|
||||
"privacy_suffix": "@:{'terms.glossary.fuer'} @:{'terms.glossary.informationen'} zu Daten @:{'terms.glossary.und'} Rechten."
|
||||
"privacy_suffix": "@:{'terms.glossary.fuer'} @:{'terms.glossary.informationen'} @:{'terms.glossary.zu'} @:{'terms.glossary.daten'} @:{'terms.glossary.und'} Rechten."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
"help_self_wash": "Starting @:{'terms.glossary.and'} ending @:{'terms.glossary.self'}-@:{'terms.glossary.service'} @:{'terms.glossary.wash'} sessions.",
|
||||
"help_payments": "@.capitalize:{'terms.glossary.orders'}, @:{'terms.glossary.invoices'}, @:{'terms.glossary.and'} @:{'terms.glossary.card'} @:{'terms.glossary.payments'}.",
|
||||
"help_account": "Login, @:{'terms.glossary.access'}, @:{'terms.glossary.and'} @:{'terms.glossary.vehicles'} on your @:{'terms.glossary.account'}.",
|
||||
"help_deletion": "Help with privacy, personal information, and account deletion.",
|
||||
"help_deletion": "Help with @:{'terms.glossary.privacy'}, personal information, and account deletion.",
|
||||
"privacy_prefix": "Also read our",
|
||||
"privacy_link": "privacy policy",
|
||||
"privacy_link": "@:{'terms.glossary.privacy'} policy",
|
||||
"privacy_suffix": "@:{'terms.glossary.for'} @:{'terms.glossary.details'} @:{'terms.glossary.about'} @:{'terms.glossary.data'} @:{'terms.glossary.and'} @:{'terms.glossary.rights'}."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"password_label": "Password",
|
||||
"password_required": "Enter your password.",
|
||||
"password_title": "Confirm your identity",
|
||||
"privacy_link": "Read the privacy policy and deletion information",
|
||||
"privacy_link": "Read the @:{'terms.glossary.privacy'} policy and deletion information",
|
||||
"retained": {
|
||||
"audit_logs": "Audit trails that must be retained for security or legal reasons",
|
||||
"customer_reference": "A limited customer-account reference where historical records require it",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"compat": {
|
||||
"privacy_policy": {
|
||||
"changes_body": "We may update this policy when the service, providers, or legal requirements change. The current version and date are published here.",
|
||||
"changes_title": "11. Changes",
|
||||
"contact_body": "Contact us about privacy, rights, or account deletion at",
|
||||
"contact_title": "12. Contact",
|
||||
"controller_intro": "Truck Wash ApS is the data controller for processing in the Truck Wash customer portal and app.",
|
||||
"controller_title": "1. Data controller",
|
||||
"data": {
|
||||
"account": "Account and contact information, including name, company, address, email, phone number, customer number, username, and security information.",
|
||||
"content": "Content you submit, including support messages, photos, attachments, and information included in error reports.",
|
||||
"service": "Vehicles and registration plates, bookings, wash history, orders, invoices, subscriptions, and payment or transaction status.",
|
||||
"technical": "Session, device, browser, network, security, and diagnostic information when needed to operate, troubleshoot, and protect the service."
|
||||
},
|
||||
"data_intro": "Depending on the features and access you use, we process:",
|
||||
"data_title": "3. Information we process",
|
||||
"deletion_body": "When signed in, you can start account deletion under Profile → Delete account. Your identity is confirmed using your password and two-factor authentication when enabled. Once accepted, access and active sessions are revoked immediately, and personal profile information is deleted or anonymized through our deletion process.",
|
||||
"deletion_retained": "Invoices, payment documentation, orders, connected wash history, and limited security or audit logs may be retained for the period required by accounting, security, or other applicable laws. They are not used for marketing after the deletion request.",
|
||||
"deletion_title": "7. Account deletion",
|
||||
"meta_description": "Privacy policy for the Truck Wash customer portal and iOS app.",
|
||||
"permissions": "The camera is used only when you choose to scan QR codes or registration plates. Location is used while the app is open to find or confirm the nearest Truck Wash department. Access can be denied in the device settings.",
|
||||
"processors": {
|
||||
"economic": "e-conomic for customer, order, invoice, and accounting processing.",
|
||||
"hosting": "Operations, hosting, communications, and security providers that process information on our behalf.",
|
||||
"microsoft": "Microsoft when you voluntarily connect Microsoft calendar or sign-in.",
|
||||
"recaptcha": "Google reCAPTCHA when risk assessment or protection against automated abuse is activated.",
|
||||
"stripe": "Stripe when a department or payment uses Stripe payment processing."
|
||||
},
|
||||
"purposes_body": "We process information to create and secure accounts, provide booking and wash services, manage vehicles, orders and invoices, process payments, send necessary service messages, provide support, prevent abuse, and comply with accounting and other legal duties. The legal bases are contract performance, legitimate interests, legal obligations, and consent where required.",
|
||||
"purposes_title": "4. Purposes and legal bases",
|
||||
"retention_body": "Profile and service data are retained while the account or customer relationship is active and then only as long as necessary for documented purposes, disputes, and applicable law. Security logs are kept for a limited period. Legally required accounting and transaction data may have a longer retention period.",
|
||||
"retention_title": "6. Retention",
|
||||
"rights": {
|
||||
"access": "Access to the personal information we process about you.",
|
||||
"complaint": "Complain to the Danish Data Protection Agency or another competent supervisory authority.",
|
||||
"correction": "Correction of inaccurate or incomplete information.",
|
||||
"erasure": "Erasure when processing is no longer necessary or legally required.",
|
||||
"objection": "Object to processing based on legitimate interests.",
|
||||
"portability": "Data portability where the rules provide that right.",
|
||||
"restriction": "Restriction of processing in the situations provided by law."
|
||||
},
|
||||
"rights_intro": "Depending on the circumstances, you have the right to:",
|
||||
"rights_title": "10. Your rights",
|
||||
"scope_body": "This policy applies to truckwash.io, the Truck Wash customer portal, and the native iOS app. It covers customers and drivers as well as administrative functions to which a user has been granted access.",
|
||||
"scope_title": "2. Scope",
|
||||
"security_body": "We use access control, encrypted transport, session protection, logging, and organizational procedures to protect information. No service can be guaranteed completely secure; contact us immediately if you suspect misuse.",
|
||||
"security_title": "8. Security",
|
||||
"sharing_intro": "We disclose information only when necessary for the service, our agreement, security, or legal requirements. Relevant categories of processors and independent controllers include:",
|
||||
"sharing_limit": "We do not sell personal information or use it for third-party advertising or tracking across other companies' apps and websites.",
|
||||
"sharing_title": "5. Providers and sharing",
|
||||
"support_link": "Go to support",
|
||||
"title": "Privacy policy",
|
||||
"tracking_body": "The portal uses necessary local storage and session mechanisms for sign-in, security, language, and functionality. External sign-in, payment, or abuse-prevention services may process technical information when their feature is used. We do not use advertising cookies or marketing analytics in the app.",
|
||||
"tracking_title": "Cookies, local storage, and tracking",
|
||||
"transfers_body": "If a provider processes information outside the EU/EEA, we require a valid transfer mechanism, such as an adequacy decision or EU Standard Contractual Clauses, together with appropriate supplementary safeguards.",
|
||||
"transfers_title": "9. International transfers",
|
||||
"updated": "Last updated: July 20, 2026"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"compat": {
|
||||
"privacy_policy": {
|
||||
"changes_body": "We may update this policy when the service, providers, or legal requirements change. The current version and date are published here.",
|
||||
"changes_title": "11. Changes",
|
||||
"contact_body": "Contact us about privacy, rights, or account deletion at",
|
||||
"contact_title": "12. Contact",
|
||||
"controller_intro": "Truck Wash ApS is the data controller for processing in the Truck Wash customer portal and app.",
|
||||
"controller_title": "1. Data controller",
|
||||
"data": {
|
||||
"account": "Account and contact information, including name, company, address, email, phone number, customer number, username, and security information.",
|
||||
"content": "Content you submit, including support messages, photos, attachments, and information included in error reports.",
|
||||
"service": "Vehicles and registration plates, bookings, wash history, orders, invoices, subscriptions, and payment or transaction status.",
|
||||
"technical": "Session, device, browser, network, security, and diagnostic information when needed to operate, troubleshoot, and protect the service."
|
||||
},
|
||||
"data_intro": "Depending on the features and access you use, we process:",
|
||||
"data_title": "3. Information we process",
|
||||
"deletion_body": "When signed in, you can start account deletion under Profile → Delete account. Your identity is confirmed using your password and two-factor authentication when enabled. Once accepted, access and active sessions are revoked immediately, and personal profile information is deleted or anonymized through our deletion process.",
|
||||
"deletion_retained": "Invoices, payment documentation, orders, connected wash history, and limited security or audit logs may be retained for the period required by accounting, security, or other applicable laws. They are not used for marketing after the deletion request.",
|
||||
"deletion_title": "7. Account deletion",
|
||||
"meta_description": "Privacy policy for the Truck Wash customer portal and iOS app.",
|
||||
"permissions": "The camera is used only when you choose to scan QR codes or registration plates. Location is used while the app is open to find or confirm the nearest Truck Wash department. Access can be denied in the device settings.",
|
||||
"processors": {
|
||||
"economic": "e-conomic for customer, order, invoice, and accounting processing.",
|
||||
"hosting": "Operations, hosting, communications, and security providers that process information on our behalf.",
|
||||
"microsoft": "Microsoft when you voluntarily connect Microsoft calendar or sign-in.",
|
||||
"recaptcha": "Google reCAPTCHA when risk assessment or protection against automated abuse is activated.",
|
||||
"stripe": "Stripe when a department or payment uses Stripe payment processing."
|
||||
},
|
||||
"purposes_body": "We process information to create and secure accounts, provide booking and wash services, manage vehicles, orders and invoices, process payments, send necessary service messages, provide support, prevent abuse, and comply with accounting and other legal duties. The legal bases are contract performance, legitimate interests, legal obligations, and consent where required.",
|
||||
"purposes_title": "4. Purposes and legal bases",
|
||||
"retention_body": "Profile and service data are retained while the account or customer relationship is active and then only as long as necessary for documented purposes, disputes, and applicable law. Security logs are kept for a limited period. Legally required accounting and transaction data may have a longer retention period.",
|
||||
"retention_title": "6. Retention",
|
||||
"rights": {
|
||||
"access": "Access to the personal information we process about you.",
|
||||
"complaint": "Complain to the Danish Data Protection Agency or another competent supervisory authority.",
|
||||
"correction": "Correction of inaccurate or incomplete information.",
|
||||
"erasure": "Erasure when processing is no longer necessary or legally required.",
|
||||
"objection": "Object to processing based on legitimate interests.",
|
||||
"portability": "Data portability where the rules provide that right.",
|
||||
"restriction": "Restriction of processing in the situations provided by law."
|
||||
},
|
||||
"rights_intro": "Depending on the circumstances, you have the right to:",
|
||||
"rights_title": "10. Your rights",
|
||||
"scope_body": "This policy applies to truckwash.io, the Truck Wash customer portal, and the native iOS app. It covers customers and drivers as well as administrative functions to which a user has been granted access.",
|
||||
"scope_title": "2. Scope",
|
||||
"security_body": "We use access control, encrypted transport, session protection, logging, and organizational procedures to protect information. No service can be guaranteed completely secure; contact us immediately if you suspect misuse.",
|
||||
"security_title": "8. Security",
|
||||
"sharing_intro": "We disclose information only when necessary for the service, our agreement, security, or legal requirements. Relevant categories of processors and independent controllers include:",
|
||||
"sharing_limit": "We do not sell personal information or use it for third-party advertising or tracking across other companies' apps and websites.",
|
||||
"sharing_title": "5. Providers and sharing",
|
||||
"support_link": "Go to support",
|
||||
"title": "Privacy policy",
|
||||
"tracking_body": "The portal uses necessary local storage and session mechanisms for sign-in, security, language, and functionality. External sign-in, payment, or abuse-prevention services may process technical information when their feature is used. We do not use advertising cookies or marketing analytics in the app.",
|
||||
"tracking_title": "Cookies, local storage, and tracking",
|
||||
"transfers_body": "If a provider processes information outside the EU/EEA, we require a valid transfer mechanism, such as an adequacy decision or EU Standard Contractual Clauses, together with appropriate supplementary safeguards.",
|
||||
"transfers_title": "9. International transfers",
|
||||
"updated": "Last updated: July 20, 2026"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -633,13 +633,30 @@ const clearBasket = () => {
|
||||
}
|
||||
|
||||
const selectedProduct = ref<PosProduct | any>(null);
|
||||
const translateBookingRestriction = (messageKey?: string | null) => {
|
||||
switch (messageKey) {
|
||||
case 'pos.restrictions.addons_not_allowed':
|
||||
return t('pos.restrictions.addons_not_allowed');
|
||||
case 'pos.restrictions.backend_rejected':
|
||||
return t('pos.restrictions.backend_rejected');
|
||||
case 'pos.restrictions.load_failed':
|
||||
return t('pos.restrictions.load_failed');
|
||||
case 'pos.restrictions.loading':
|
||||
return t('pos.restrictions.loading');
|
||||
case 'pos.restrictions.restricted_items_removed':
|
||||
return t('pos.restrictions.restricted_items_removed');
|
||||
default:
|
||||
return t('pos.restrictions.product_not_allowed');
|
||||
}
|
||||
};
|
||||
|
||||
const clearRestrictedBookingSelection = (messageKey = 'pos.restrictions.product_not_allowed') => {
|
||||
selectedProduct.value = null;
|
||||
washInterior.value = false;
|
||||
washExterior.value = true;
|
||||
productAssociatedWithInteriorWash.value = null;
|
||||
clearBasket();
|
||||
bookingRestrictionWarning.value = t(messageKey);
|
||||
bookingRestrictionWarning.value = translateBookingRestriction(messageKey);
|
||||
};
|
||||
|
||||
const onSelectionInvalidated = (payload?: { restriction?: { messageKey?: string } }) => {
|
||||
@@ -807,7 +824,7 @@ watch(() => customerNumber.value, () => { loadWashCertificateProduct(); });
|
||||
const onClickInteriorWashButton = () => {
|
||||
const restriction = getBookingAddonRestriction(interiorAddon.value);
|
||||
if (!interiorAddon.value || restriction.restricted || !isWashCertificateAllowed.value) {
|
||||
bookingRestrictionWarning.value = t(
|
||||
bookingRestrictionWarning.value = translateBookingRestriction(
|
||||
!isWashCertificateAllowed.value
|
||||
? washCertificateRestriction.value.messageKey
|
||||
: restriction.messageKey || 'pos.restrictions.product_not_allowed'
|
||||
@@ -825,7 +842,7 @@ const onClickExteriorWashButton = () => {
|
||||
: selectedProduct.value;
|
||||
const restriction = getBookingProductRestriction(exteriorProduct);
|
||||
if (!exteriorProduct || restriction.restricted) {
|
||||
bookingRestrictionWarning.value = t(restriction.messageKey || 'pos.restrictions.product_not_allowed');
|
||||
bookingRestrictionWarning.value = translateBookingRestriction(restriction.messageKey);
|
||||
washExterior.value = false;
|
||||
return;
|
||||
}
|
||||
@@ -1135,7 +1152,7 @@ const onSummaryConfirm = () => {
|
||||
}
|
||||
if (!guestMode.value && (!bookingRulesReady.value || completeBasket.value.length === 0)) {
|
||||
showSummary.value = false;
|
||||
bookingRestrictionWarning.value = t(
|
||||
bookingRestrictionWarning.value = translateBookingRestriction(
|
||||
bookingRulesReady.value
|
||||
? 'pos.restrictions.product_not_allowed'
|
||||
: customer_attributes_status.value === 'error'
|
||||
|
||||
@@ -21,8 +21,27 @@ const principalType = computed(
|
||||
() => deletionState.value?.principal_type || (SessionUser.isSubuser.value ? "subuser" : "customer")
|
||||
);
|
||||
const canRequestDeletion = computed(() => deletionState.value?.status === "available" && !isSubmitting.value);
|
||||
const statusKey = computed(() => `user_dashboard.profile.deletion.status.${deletionState.value?.status || "unavailable"}`);
|
||||
const principalImpactKey = computed(() => `user_dashboard.profile.deletion.impact.${principalType.value}`);
|
||||
const principalImpact = computed(() =>
|
||||
principalType.value === "subuser"
|
||||
? t("user_dashboard.profile.deletion.impact.subuser")
|
||||
: t("user_dashboard.profile.deletion.impact.customer")
|
||||
);
|
||||
const statusLabel = computed(() => {
|
||||
switch (deletionState.value?.status) {
|
||||
case "available":
|
||||
return t("user_dashboard.profile.deletion.status.available");
|
||||
case "requested":
|
||||
return t("user_dashboard.profile.deletion.status.requested");
|
||||
case "processing":
|
||||
return t("user_dashboard.profile.deletion.status.processing");
|
||||
case "completed":
|
||||
return t("user_dashboard.profile.deletion.status.completed");
|
||||
case "failed":
|
||||
return t("user_dashboard.profile.deletion.status.failed");
|
||||
default:
|
||||
return t("user_dashboard.profile.deletion.status.unavailable");
|
||||
}
|
||||
});
|
||||
|
||||
const retainedDataCategories = computed(() => {
|
||||
const categories = deletionState.value?.retained_data_categories || [];
|
||||
@@ -46,6 +65,15 @@ const retainedDataItems = computed(() => {
|
||||
"customer_reference",
|
||||
"driver_reference",
|
||||
]);
|
||||
const labels = {
|
||||
invoices_payments_accounting: t("user_dashboard.profile.deletion.retained.invoices_payments_accounting"),
|
||||
orders_wash_history: t("user_dashboard.profile.deletion.retained.orders_wash_history"),
|
||||
security_audit_logs: t("user_dashboard.profile.deletion.retained.security_audit_logs"),
|
||||
legal_obligations: t("user_dashboard.profile.deletion.retained.legal_obligations"),
|
||||
customer_reference: t("user_dashboard.profile.deletion.retained.customer_reference"),
|
||||
driver_reference: t("user_dashboard.profile.deletion.retained.driver_reference"),
|
||||
other: t("user_dashboard.profile.deletion.retained.other"),
|
||||
};
|
||||
const items = retainedDataCategories.value.map((category) => {
|
||||
const normalized = String(category || "")
|
||||
.trim()
|
||||
@@ -53,7 +81,7 @@ const retainedDataItems = computed(() => {
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
const key = knownCategories.has(normalized) ? normalized : "other";
|
||||
return { key, label: t(`user_dashboard.profile.deletion.retained.${key}`) };
|
||||
return { key, label: labels[key] };
|
||||
});
|
||||
|
||||
return [...new Map(items.map((item) => [item.key, item])).values()];
|
||||
@@ -173,7 +201,7 @@ const requestDeletion = async () => {
|
||||
|
||||
const warning = await Swal.fire({
|
||||
title: t("user_dashboard.profile.deletion.warning_title"),
|
||||
text: t(principalImpactKey.value),
|
||||
text: principalImpact.value,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
focusCancel: true,
|
||||
@@ -261,7 +289,7 @@ onMounted(loadDeletionState);
|
||||
|
||||
<template v-else>
|
||||
<div class="notification is-danger is-light mb-4">
|
||||
<p class="has-text-weight-semibold mb-2">{{ t(principalImpactKey) }}</p>
|
||||
<p class="has-text-weight-semibold mb-2">{{ principalImpact }}</p>
|
||||
<p>{{ t("user_dashboard.profile.deletion.immediate_effect") }}</p>
|
||||
</div>
|
||||
|
||||
@@ -279,7 +307,7 @@ onMounted(loadDeletionState);
|
||||
|
||||
<p class="mb-3" data-testid="account-deletion-status">
|
||||
<strong>{{ t("user_dashboard.profile.deletion.status_label") }}:</strong>
|
||||
{{ t(statusKey) }}
|
||||
{{ statusLabel }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 57 KiB |
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
||||
test("mobile header clears the simulated iPhone safe area", async ({ page }, testInfo) => {
|
||||
test.skip(!["chromium-mobile", "webkit-mobile"].includes(testInfo.project.name), "Mobile browser coverage only.");
|
||||
|
||||
await page.goto("/login", { waitUntil: "domcontentloaded" });
|
||||
await page.goto("/guest/home", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const header = page.locator("nav.mobile-header-section");
|
||||
const spacer = page.locator(".navbar-spacer");
|
||||
|
||||
@@ -10,7 +10,9 @@ async function expectOpaqueWhiteBackground(relativePath) {
|
||||
const { data, width, height } = image.bitmap;
|
||||
|
||||
for (let offset = 3; offset < data.length; offset += 4) {
|
||||
expect(data[offset], `${relativePath} contains transparent pixels`).toBe(255);
|
||||
if (data[offset] !== 255) {
|
||||
throw new Error(`${relativePath} contains transparent pixels at byte offset ${offset}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [x, y] of [
|
||||
@@ -30,26 +32,20 @@ async function expectOpaqueWhiteBackground(relativePath) {
|
||||
|
||||
describe("mobile app icon backgrounds", () => {
|
||||
it("uses an opaque white background for the iOS marketing icon", async () => {
|
||||
await expectOpaqueWhiteBackground(
|
||||
"ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png",
|
||||
);
|
||||
await expectOpaqueWhiteBackground("ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png");
|
||||
});
|
||||
|
||||
it("uses an opaque white background for Android store and launcher icons", async () => {
|
||||
await expectOpaqueWhiteBackground("store_icon.png");
|
||||
|
||||
for (const density of ["mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"]) {
|
||||
await expectOpaqueWhiteBackground(
|
||||
`android/app/src/main/res/mipmap-${density}/ic_launcher.png`,
|
||||
);
|
||||
await expectOpaqueWhiteBackground(
|
||||
`android/app/src/main/res/mipmap-${density}/ic_launcher_round.png`,
|
||||
);
|
||||
await expectOpaqueWhiteBackground(`android/app/src/main/res/mipmap-${density}/ic_launcher.png`);
|
||||
await expectOpaqueWhiteBackground(`android/app/src/main/res/mipmap-${density}/ic_launcher_round.png`);
|
||||
}
|
||||
|
||||
const adaptiveBackground = await readFile(
|
||||
join(root, "android/app/src/main/res/values/ic_launcher_background.xml"),
|
||||
"utf8",
|
||||
"utf8"
|
||||
);
|
||||
expect(adaptiveBackground).toContain("#FFFFFF");
|
||||
});
|
||||
|
||||
@@ -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}:`);
|
||||
}
|
||||
});
|
||||
});
|
||||