Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/native-rn-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,16 @@ jobs:

- name: Cross-check (mock ≡ real RN ${{ matrix.rn }})
run: bun run crosscheck

# The per-cell report feeds the published fidelity matrix (pages.yml pulls
# the latest successful run's artifacts and renders
# website/guide/fidelity-matrix.md at deploy time). Uploaded on failure
# too — a diverging cell's report is the forensic record.
- name: Upload cross-check report
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: crosscheck-report-rn${{ matrix.rn }}-${{ matrix.vitest }}
path: packages/vitest-native/crosscheck/.out/report.json
if-no-files-found: ignore
retention-days: 30
40 changes: 40 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ on:
paths:
- 'website/**'
- '.github/workflows/pages.yml'
# Redeploy when the RN × Vitest matrix completes on main, so the published
# fidelity matrix reflects the latest run's per-cell cross-check reports.
workflow_run:
workflows: ['Native engine — Vitest × RN matrix']
types: [completed]
branches: [main]
workflow_dispatch:

permissions:
Expand All @@ -17,12 +23,19 @@ concurrency:

jobs:
deploy:
# workflow_run's `branches` filter matches the triggering run's HEAD branch,
# so a fork PR's matrix run (fork default branch is `main`) would fire this
# too. The artifact-selection step below never trusts pull_request runs, so
# such a trigger could only waste a deploy — skip it outright.
if: github.event_name != 'workflow_run' || github.event.workflow_run.event != 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10

permissions:
pages: write
id-token: write
# Read the matrix workflow's artifacts (per-cell cross-check reports).
actions: read

environment:
name: github-pages
Expand All @@ -36,6 +49,33 @@ jobs:

- run: bun install --frozen-lockfile

# Render the fidelity matrix from the latest successful matrix run's
# per-cell cross-check reports. Best-effort: with no run or no artifacts
# (expired retention, first deploy), the committed placeholder page ships
# instead — the site build must never depend on matrix availability.
#
# SECURITY: only non-pull_request runs are eligible. `--branch main`
# matches the run's HEAD branch name, and the matrix runs on
# pull_request — a fork PR opened from a branch named `main` (the fork
# default) would otherwise qualify, and pull_request runs execute the
# PR's OWN workflow file, so its artifacts are attacker-controlled.
# push/schedule/workflow_dispatch runs execute only trusted main-branch
# code.
- name: Render fidelity matrix from the latest matrix run
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
run_id=$(gh run list --repo "$GITHUB_REPOSITORY" \
--workflow native-rn-matrix.yml --branch main --status success \
--limit 10 --json databaseId,event \
--jq 'first(.[] | select(.event != "pull_request")).databaseId' || true)
if [ -n "$run_id" ]; then
gh run download "$run_id" --repo "$GITHUB_REPOSITORY" \
--pattern 'crosscheck-report-*' --dir /tmp/crosscheck-reports || true
fi
node packages/vitest-native/scripts/fidelity-matrix.mjs --reports /tmp/crosscheck-reports

- name: Build website
run: bun run build:website

Expand Down
2 changes: 1 addition & 1 deletion packages/vitest-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@
"prepublishOnly": "bun run build && bun run test && bun run test:native && bun run test:native:navigation && bun run test:native:android && bun run test:native:hot && bun run test:native:hot:isolation && bun run test:native:hot:soak && bun run fidelity:check && bun run test:consumers && bun run lint && bun run typecheck",
"crosscheck": "node scripts/crosscheck.mjs",
"fidelity:report": "node scripts/fidelity-report.mjs",
"fidelity:check": "node scripts/fidelity-report.mjs --check",
"fidelity:check": "node scripts/fidelity-report.mjs --check && node scripts/fidelity-matrix.mjs --check",
"validate:hot-parity": "node validation/idiomatic/scale/run-compare.mjs"
},
"dependencies": {
Expand Down
10 changes: 10 additions & 0 deletions packages/vitest-native/scripts/crosscheck.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ function resolveReactNativeVersion() {
}
}

function resolveVitestVersion() {
try {
const req = createRequire(path.join(root, "package.json"));
return req("vitest/package.json").version;
} catch {
return null;
}
}

function runEngine(engine) {
const out = path.join(outDir, `${engine}.json`);
fs.rmSync(out, { force: true });
Expand Down Expand Up @@ -76,6 +85,7 @@ console.log(`${matched}/${names.length} probes match between mock and real React
const failureByName = new Map(failures.map((f) => [f.name, f]));
const report = {
reactNativeVersion: resolveReactNativeVersion(),
vitestVersion: resolveVitestVersion(),
generatedAt: new Date().toISOString(),
summary: { total: names.length, matching: matched },
probes: names.map((name) => {
Expand Down
179 changes: 179 additions & 0 deletions packages/vitest-native/scripts/fidelity-matrix.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// Fidelity matrix page: aggregate the per-cell cross-check reports produced by
// the CI matrix (one report.json per RN × Vitest cell) into a published
// probes-across-versions dashboard.
//
// CI already proves mock ≡ real-RN parity per RN version on every commit — but
// it used to throw the per-cell reports away, leaving the published fidelity
// page a single-version snapshot. This script turns those reports into the
// dashboard page:
//
// node scripts/fidelity-matrix.mjs --reports <dir> # <dir>/**/report.json
// node scripts/fidelity-matrix.mjs # placeholder page
//
// The page is rendered at DEPLOY time by pages.yml from the latest successful
// matrix run's artifacts; the committed website/guide/fidelity-matrix.md is a
// placeholder that keeps local site builds (and VitePress dead-link checks)
// working and is overwritten in the deploy workspace before the build.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(root, "..", "..");

function argValue(flag) {
const i = process.argv.indexOf(flag);
return i !== -1 ? process.argv[i + 1] : undefined;
}
const reportsDir = argValue("--reports");
const outPath = argValue("--out") ?? path.join(repoRoot, "website", "guide", "fidelity-matrix.md");

// Same VitePress hazards as fidelity-report.mjs — bare tags, table pipes, and
// Vue interpolation in free text break the site build — plus backticks, which
// would open a code span and turn the rest of the row into live markup. Every
// report field is treated as untrusted data (the reports are CI artifacts),
// so ALL of them render through this escape, code-span styling deliberately
// forgone.
const cell = (s) =>
String(s ?? "")
// Backslashes first — escaping them after the fact would re-arm the very
// characters the later replacements neutralize (CodeQL js/incomplete-sanitization).
.replace(/\\/g, "\\\\")
.replace(/\|/g, "\\|")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/`/g, "&#96;")
.replace(/\{\{/g, "&#123;&#123;")
.replace(/\}\}/g, "&#125;&#125;");

/** Recursively collect report.json files under dir. */
function collectReports(dir) {
const out = [];
if (!dir || !fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...collectReports(p));
else if (entry.name === "report.json") out.push(p);
}
return out;
}

/**
* The artifact directory name carries the matrix axes
* (crosscheck-report-rn<version>-<vitest-flavor>); the report body carries the
* RESOLVED versions. Prefer the body, fall back to the directory name.
*/
function describeCell(reportPath, report) {
const dirName = path.basename(path.dirname(reportPath));
const m = /^crosscheck-report-rn(.+?)-(locked|latest-supported)$/.exec(dirName);
return {
rn: report.reactNativeVersion ?? m?.[1] ?? "unknown",
vitest: report.vitestVersion ?? (m?.[2] === "latest-supported" ? "latest 4.x" : "locked"),
flavor: m?.[2] ?? "locked",
};
}

const HEADER = `<!--
GENERATED FILE — do not edit by hand.
The committed version is a placeholder; pages.yml regenerates this page at
deploy time from the latest CI matrix run's cross-check reports
(scripts/fidelity-matrix.mjs).
-->
# Fidelity matrix

The [behavioral cross-check](/guide/fidelity) runs the same probe corpus under
the mock engine **and** under real React Native — and CI runs it against
**every supported React Native version** in the
[Vitest × RN matrix](https://github.com/danfry1/vitest-native/actions/workflows/native-rn-matrix.yml).
This page is generated from that matrix's own reports, so every number below
was produced by CI, not written by hand.
`;

const reportFiles = collectReports(reportsDir);
let body;

if (reportFiles.length === 0) {
body = `
::: info No matrix data in this build
This page is populated from the latest CI matrix run when the site is deployed.
A local or matrix-less build shows this placeholder. The single-version
[Fidelity Report](/guide/fidelity) is always available and drift-gated in CI.
:::
`;
} else {
const cells = reportFiles
.map((p) => {
const report = JSON.parse(fs.readFileSync(p, "utf8"));
return { ...describeCell(p, report), report };
})
.sort(
(a, b) =>
a.rn.localeCompare(b.rn, undefined, { numeric: true }) || a.flavor.localeCompare(b.flavor),
);

const allGreen = cells.every((c) => c.report.summary.matching === c.report.summary.total);
const rows = cells
.map(({ rn, vitest, report }) => {
const { matching, total } = report.summary;
const status = matching === total ? "✅" : "❌";
const when = (report.generatedAt ?? "").slice(0, 10);
return `| ${cell(rn)} | ${cell(vitest)} | ${status} ${matching}/${total} | ${cell(when)} |`;
})
.join("\n");

// NOTE: crosscheck.mjs exits non-zero on any divergence, so CI's
// artifact-selection (latest SUCCESSFUL run) never feeds this branch — the
// deployed page shows the last all-green run while CI is red. The rendering
// exists for manually-supplied report directories and forensics; do not
// remove it as dead code.
const divergences = cells.flatMap(({ rn, vitest, report }) =>
report.probes
.filter((p) => !p.match)
.map(
(p) =>
`| ${cell(p.name)} | ${cell(rn)} | ${cell(vitest)} | ${cell(p.reason ?? "diverged")} |`,
),
);

body = `
${allGreen ? `**Every probe matches on every gated React Native version.**` : `**Divergences detected — see the table below.**`}

| React Native | Vitest | Probes matching | Generated |
| --- | --- | --- | --- |
${rows}

## Divergences

${
divergences.length
? `| Probe | React Native | Vitest | Reason |\n| --- | --- | --- | --- |\n${divergences.join("\n")}`
: "_None. Every probe matched in every cell of the latest matrix run._"
}
`;
}

const content = `${HEADER}${body}`;

if (process.argv.includes("--check")) {
// Drift gate for the COMMITTED page: it must be exactly the placeholder
// render — real matrix data belongs only in the deploy workspace, never in
// the repository.
if (reportFiles.length > 0) {
console.error("✗ --check validates the committed placeholder; run it without --reports.");
process.exit(1);
}
const current = fs.existsSync(outPath) ? fs.readFileSync(outPath, "utf8") : null;
if (current !== content) {
console.error(
`✗ ${path.relative(repoRoot, outPath)} is not the placeholder render — regenerate with \`node scripts/fidelity-matrix.mjs\` and commit.`,
);
process.exit(1);
}
console.log("✓ committed fidelity-matrix placeholder is up to date");
} else {
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, content);
console.log(
`✓ fidelity matrix page rendered (${reportFiles.length} report${reportFiles.length === 1 ? "" : "s"}) → ${path.relative(repoRoot, outPath)}`,
);
}
4 changes: 4 additions & 0 deletions packages/vitest-native/scripts/fidelity-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ const knownDiffs = fs.existsSync(knownDiffsPath)
// any cell rendered as free text (not inside a code span).
const cell = (s) =>
String(s ?? "")
// Backslashes first — escaping them after the fact would re-arm the very
// characters the later replacements neutralize (CodeQL js/incomplete-sanitization).
.replace(/\\/g, "\\\\")
.replace(/\|/g, "\\|")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
Expand Down Expand Up @@ -136,6 +139,7 @@ generated from the corpus itself, so the numbers below are exactly what ships.

- **${summary.matching} / ${summary.total} probes** match between the mock engine and real React Native.
- ${ciLine}
- Per-version results straight from the CI matrix: [Fidelity Matrix](/guide/fidelity-matrix).
- Reproduce it yourself: \`bun run crosscheck\`.

The \`native\` engine needs no cross-check — it *is* real React Native.
Expand Down
Loading
Loading