mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(ci): repeat desktop Tauri tests and helper-dependent Rust binaries
Address the two High findings of review 5659898932 on #8613: a changed Rust test under desktop/src-tauri, or a changed helper module under a crate's tests/, produced an empty flake selection and every repeat lane skipped, a false "nothing selected" green. Desktop Tauri Rust is now a runner of its own rather than an unclassified path. rustFile places a Rust file in its workspace (the root crates, or desktop/src-tauri and its crates/*), and classify, cratePackage, rustTarget, rustModulePath and rustGroups read that instead of assuming crates/<crate>/. The new flake-tauri lane mirrors the Desktop Tauri core lane's setup (its apt packages, sidecar stubs and desktop-core rust-cache, restored read-only) and runs the same script against desktop/src-tauri/Cargo.toml. nextest reads its configuration from the workspace root, so that workspace gets its own .config/nextest.toml with the flake profile, and the Postgres exclusion applies only in the root workspace: nextest rejects a binary(/^postgres_/) predicate where no such binary exists, which the Tauri workspace's first live run showed. Supporting the surface for real was straightforward, since the workspace is an ordinary cargo workspace that nextest lists and runs by manifest path, so no out-of-scope declaration was needed. A tests/** file without test functions is a helper that integration binaries compile in with `mod common;`. select now finds the package's integration targets that include it (rustIncludes reads `mod <first>;` declarations with comments and strings blanked, `#[path]` attributes, and a tests/<dir>/main.rs owning its directory; a helper no target names falls back to every target) and selects each of those binaries whole, so the ignored and infrastructure exclusions still apply, with a row naming the importers. A helper and its importer changed together select each test once. A src/** file without test functions still selects nothing; a comment beside the row records that this is deliberate, since the regular lanes run what depends on it. Regression tests go through the real select entry point over a synthetic head tree (HEAD with a comment appended, as the reviewer probed): a changed desktop/src-tauri/tests/rodio_mixer_diagnostic.rs yields its three tests under tauri=, and a comment-only change to crates/buzz-agent/tests/common/mod.rs yields rust= entries in exactly anthropic_continuation, fake_llm, permission_boundary and regressions, the four files declaring `mod common;`. Verified live: cargo nextest list with the produced -p/--test flags and filterset matches all 85 entries, and `flake-check.mjs tauri --iterations=2` on the Tauri selection ran green end to end with its JUnit report under desktop/src-tauri/target/nextest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
66
.github/workflows/_ci-flake.yml
vendored
66
.github/workflows/_ci-flake.yml
vendored
@@ -23,6 +23,7 @@ jobs:
|
||||
contents: read
|
||||
outputs:
|
||||
rust: ${{ steps.select.outputs.rust }}
|
||||
tauri: ${{ steps.select.outputs.tauri }}
|
||||
node: ${{ steps.select.outputs.node }}
|
||||
playwright: ${{ steps.select.outputs.playwright }}
|
||||
playwright_shards: ${{ steps.select.outputs.playwright_shards }}
|
||||
@@ -106,6 +107,71 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
flake-tauri:
|
||||
name: Flake check (Desktop Tauri)
|
||||
needs: [flake-select]
|
||||
if: needs.flake-select.outputs.tauri != ''
|
||||
runs-on: ubuntu-latest
|
||||
# Same bound as a Desktop Tauri lane (_ci-desktop.yml): ci-apt-retry.sh
|
||||
# bounds the apt step at ~34m worst case and a cold build needs ~15m.
|
||||
timeout-minutes: 55
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1
|
||||
# Restores the Desktop Tauri core lane's cache (_ci-desktop.yml)
|
||||
# read-only, under the same constraints as flake-rust's restore above:
|
||||
# the workspace, shared-key and environment hash must stay identical to
|
||||
# that job's.
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
||||
with:
|
||||
workspaces: desktop/src-tauri
|
||||
shared-key: desktop-core
|
||||
save-if: false
|
||||
- name: Install Tauri dependencies (Linux)
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
run: |
|
||||
CI_APT_ATTEMPT_SECONDS=90 scripts/ci-apt-retry.sh sudo apt-get update
|
||||
scripts/ci-apt-retry.sh sudo apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
curl \
|
||||
file \
|
||||
libasound2-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
libgtk-3-dev \
|
||||
librsvg2-dev \
|
||||
libssl-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libxdo-dev \
|
||||
patchelf \
|
||||
wget
|
||||
- name: Install cargo-nextest
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
with:
|
||||
tool: cargo-nextest@0.9.136
|
||||
# tauri-build validates the sidecar binaries at compile time; the Desktop
|
||||
# Tauri recipes create the same empty stubs.
|
||||
- name: Create sidecar stubs
|
||||
run: just _ensure-sidecar-stubs
|
||||
# Same script as flake-rust, against the Tauri workspace's manifest and
|
||||
# its own .config/nextest.toml `flake` profile.
|
||||
- name: Repeat changed desktop Tauri tests
|
||||
env:
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
SELECTED: ${{ needs.flake-select.outputs.tauri }}
|
||||
run: node scripts/flake-check.mjs tauri --iterations=10 $SELECTED
|
||||
- name: Desktop Tauri iteration reports
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: flake-tauri
|
||||
path: test-results/flake
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
flake-node:
|
||||
name: Flake check (node:test)
|
||||
needs: [flake-select]
|
||||
|
||||
@@ -305,11 +305,13 @@ This is the same check that runs in CI. PRs that fail `just ci` will not be
|
||||
merged. If `just ci` fails on formatting, `just fix-all` fixes it in one shot (`rustfmt` + Tauri fmt + desktop, web, and mobile formatters).
|
||||
|
||||
Pull requests also get an advisory **flake check**. `scripts/flake-check.mjs
|
||||
select` maps the diff against the base branch to the Rust tests, desktop
|
||||
`node --test` files, Playwright smoke journeys and Flutter test files it
|
||||
changed: a hunk inside a test body selects that test; a change anywhere else
|
||||
in a test file (imports, helpers, hooks, the code under test beside Rust
|
||||
tests) or a new file selects the whole file. The `Flake check (…)` lanes then
|
||||
select` maps the diff against the base branch to the Rust tests (in the root
|
||||
and desktop Tauri workspaces), desktop `node --test` files, Playwright smoke
|
||||
journeys and Flutter test files it changed: a hunk inside a test body selects
|
||||
that test; a change anywhere else in a test file (imports, helpers, hooks,
|
||||
the code under test beside Rust tests) or a new file selects the whole file,
|
||||
and a change to a helper module under a crate's `tests/` selects every
|
||||
integration test binary that includes it. The `Flake check (…)` lanes then
|
||||
run that selection ten times with retries off; a repetition that fails is
|
||||
usually a race in the test or the code under it, worth fixing before it
|
||||
lands, though a test that needs infrastructure or the regular lane's
|
||||
|
||||
14
desktop/src-tauri/.config/nextest.toml
Normal file
14
desktop/src-tauri/.config/nextest.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
# nextest reads its configuration from the workspace root, so the desktop
|
||||
# Tauri workspace carries its own. Only the advisory pull-request flake lane
|
||||
# (scripts/flake-check.mjs tauri) runs nextest here; the Desktop Tauri lanes
|
||||
# use `cargo test`.
|
||||
|
||||
[profile.flake]
|
||||
# Mirrors the root .config/nextest.toml profile: the lane repeats a selection
|
||||
# and must see every failed repetition, so no retries; the JUnit report gives
|
||||
# it per-test statuses.
|
||||
retries = 0
|
||||
fail-fast = false
|
||||
|
||||
[profile.flake.junit]
|
||||
path = "junit.xml"
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { posix as path } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = fileURLToPath(new URL("../", import.meta.url));
|
||||
@@ -38,7 +40,43 @@ export const MAX_PLAYWRIGHT_JOURNEYS = Math.floor(
|
||||
// one past the journey cap, is a sweeping refactor rather than a test change
|
||||
// worth ten repetitions, so the lane skips rather than overrunning its timeout.
|
||||
export const MAX_OUTPUT_BYTES = 64 * 1024;
|
||||
export const runners = ["rust", "node", "playwright", "dart"];
|
||||
export const runners = ["rust", "tauri", "node", "playwright", "dart"];
|
||||
// The two cargo workspaces, by runner: the root workspace's crates, and the
|
||||
// desktop Tauri workspace (desktop/src-tauri and its crates/*), which the
|
||||
// Desktop Tauri lane (_ci-desktop.yml) builds with its own manifest, system
|
||||
// dependencies and cache. Each is repeated by its own lane with nextest run
|
||||
// against that manifest.
|
||||
export const rustWorkspaces = {
|
||||
rust: "Cargo.toml",
|
||||
tauri: "desktop/src-tauri/Cargo.toml",
|
||||
};
|
||||
const rustLayouts = [
|
||||
{
|
||||
runner: "rust",
|
||||
pattern: /^(crates\/[^/]+)\/(src|tests)\/((?!fixtures\/).*)\.rs$/,
|
||||
},
|
||||
{
|
||||
runner: "tauri",
|
||||
pattern:
|
||||
/^(desktop\/src-tauri(?:\/crates\/[^/]+)?)\/(src|tests)\/((?!fixtures\/).*)\.rs$/,
|
||||
},
|
||||
];
|
||||
|
||||
// A Rust file's place in its workspace: the runner, its package directory,
|
||||
// whether it is under src or tests, and its path below that without `.rs`.
|
||||
export function rustFile(file) {
|
||||
for (const { runner, pattern } of rustLayouts) {
|
||||
const match = pattern.exec(file);
|
||||
if (match)
|
||||
return {
|
||||
runner,
|
||||
dir: match[1],
|
||||
kind: match[2],
|
||||
parts: match[3].split("/"),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
// Mirrors desktop/package.json "test": plain helpers run under the TS loader;
|
||||
// jsdom tests also preload the DOM shim and force-exit past their timers.
|
||||
export const nodeTestArgs = (file) =>
|
||||
@@ -58,19 +96,34 @@ export const dartDefine =
|
||||
"--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example";
|
||||
// Mirrors the postgres-ci default-filter in .config/nextest.toml: those tests
|
||||
// are #[ignore]d and need Postgres, Redis, S3 or MinIO, so a selection that
|
||||
// reached one would fail every repetition without being flaky.
|
||||
const infraTests =
|
||||
"test(/postgres_tests::/) | binary(/^postgres_/) | test(/(^|::)external_infra[^:]*::/)";
|
||||
// reached one would fail every repetition without being flaky. The convention
|
||||
// is the root workspace's; nextest rejects a `binary()` predicate matching no
|
||||
// test binary in the workspace, and the Tauri workspace has none, so it gets
|
||||
// no exclusion.
|
||||
const infraTests = {
|
||||
rust: "test(/postgres_tests::/) | binary(/^postgres_/) | test(/(^|::)external_infra[^:]*::/)",
|
||||
};
|
||||
|
||||
// The nextest filterset listing a workspace's candidates for the selected
|
||||
// in-file test paths, less its infrastructure-backed tests.
|
||||
export function rustFilterset(runner, paths) {
|
||||
const suffixes = [...new Set(paths)]
|
||||
.map((path) => `test(/(^|::)${path}$/)`)
|
||||
.join(" | ");
|
||||
return infraTests[runner]
|
||||
? `(${suffixes}) & !(${infraTests[runner]})`
|
||||
: `(${suffixes})`;
|
||||
}
|
||||
|
||||
// Mirrors desktop/package.json "test", desktop/playwright.config.ts testDir,
|
||||
// the root Cargo workspace's crates and mobile's test directory. Paths are
|
||||
// the two cargo workspaces' packages and mobile's test directory. Paths are
|
||||
// later split on whitespace in a shell, so admit only plain names. The
|
||||
// desktop `*.perf.ts` measurements are deliberately not specs here: they time
|
||||
// the app rather than assert on it, so a slow repetition is not a flake.
|
||||
export function classify(file) {
|
||||
if (!/^[\w./@-]+$/.test(file)) return undefined;
|
||||
if (/^crates\/[^/]+\/(?:src|tests)\/(?!fixtures\/).*\.rs$/.test(file))
|
||||
return "rust";
|
||||
const rust = rustFile(file);
|
||||
if (rust) return rust.runner;
|
||||
if (
|
||||
/^desktop\/(?:src\/.*\.(?:test|jsdom-test)\.mjs|scripts\/[^/]+\.test\.mjs)$/.test(
|
||||
file,
|
||||
@@ -350,7 +403,8 @@ export function selectTests(
|
||||
// Rust tests a diff selects: hunks inside a test body select that test, any
|
||||
// other hunk selects every test in the file (the module's code under test
|
||||
// lives beside them), and `#[ignore]`d tests own their lines but never run.
|
||||
// A file without test functions is not a test file and selects nothing.
|
||||
// A file without test functions selects nothing of its own; `select` decides
|
||||
// what that means by where the file lives.
|
||||
export function selectRust(
|
||||
diff,
|
||||
source,
|
||||
@@ -394,9 +448,9 @@ const summarize = (text) => {
|
||||
};
|
||||
const cell = (value) => String(value).replaceAll("|", "\\|");
|
||||
|
||||
const crateDir = (file) => `${root}crates/${file.split("/")[1]}`;
|
||||
const crateDir = (file) => `${root}${rustFile(file).dir}`;
|
||||
|
||||
// The `[package]` name of the crate owning a file under crates/.
|
||||
// The `[package]` name of the package owning a Rust file.
|
||||
export function cratePackage(file) {
|
||||
const manifest = readFileSync(`${crateDir(file)}/Cargo.toml`, "utf8");
|
||||
return /^\[package\][\s\S]*?^name\s*=\s*"([^"]+)"/m.exec(manifest)[1];
|
||||
@@ -409,16 +463,16 @@ export function cratePackage(file) {
|
||||
// of that name. A file placed by a custom `[[test]]` path builds every target
|
||||
// instead.
|
||||
export function rustTarget(file) {
|
||||
const [, , kind, first, second] = file.split("/");
|
||||
const { kind, parts } = rustFile(file);
|
||||
if (kind === "src") {
|
||||
const lib =
|
||||
existsSync(`${crateDir(file)}/src/lib.rs`) ||
|
||||
/^\[lib\]/m.test(readFileSync(`${crateDir(file)}/Cargo.toml`, "utf8"));
|
||||
return lib ? [["--lib"], ["--bins"]] : [["--bins"]];
|
||||
}
|
||||
if (second === undefined) return [["--test", first.replace(/\.rs$/, "")]];
|
||||
return existsSync(`${crateDir(file)}/tests/${first}/main.rs`)
|
||||
? [["--test", first]]
|
||||
if (parts.length === 1) return [["--test", parts[0]]];
|
||||
return existsSync(`${crateDir(file)}/tests/${parts[0]}/main.rs`)
|
||||
? [["--test", parts[0]]]
|
||||
: [];
|
||||
}
|
||||
|
||||
@@ -428,7 +482,8 @@ export function rustTarget(file) {
|
||||
// is x. A `#[path]` attribute can break this; repeatRust then falls back to the
|
||||
// in-file path alone.
|
||||
export function rustModulePath(file) {
|
||||
const [, , kind, ...parts] = file.replace(/\.rs$/, "").split("/");
|
||||
const { kind, parts: below } = rustFile(file);
|
||||
const parts = [...below];
|
||||
if (kind === "tests") parts.shift();
|
||||
else if (parts[0] === "bin") parts.splice(0, 2);
|
||||
if (["mod", "main"].includes(parts.at(-1)) || parts.join() === "lib")
|
||||
@@ -436,6 +491,60 @@ export function rustModulePath(file) {
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Whether an integration-test target (tests/<stem>.rs or tests/<dir>/main.rs)
|
||||
// compiles a helper file under tests/ into its binary: a main.rs owns its
|
||||
// directory, and any target's source declares `mod <first>;` for the
|
||||
// helper's first path component under tests/ (a crate root resolves
|
||||
// `mod common;` to tests/common/mod.rs or tests/common.rs), or a `#[path]`
|
||||
// attribute names the helper's file or the mod.rs of a directory holding it.
|
||||
// Declarations are read with comments and strings blanked; `#[path]` strings
|
||||
// are read raw, since blanking would erase them.
|
||||
export function rustIncludes(target, source, helper) {
|
||||
const targetDir = path.dirname(target);
|
||||
const [first] = rustFile(helper).parts;
|
||||
if (path.basename(target) === "main.rs" && helper.startsWith(`${targetDir}/`))
|
||||
return true;
|
||||
if (new RegExp(`\\bmod\\s+${first}\\s*;`).test(blankRust(source)))
|
||||
return true;
|
||||
for (const [, named] of source.matchAll(/#\[\s*path\s*=\s*"([^"]+)"\s*\]/g)) {
|
||||
const resolved = path.normalize(path.join(targetDir, named));
|
||||
if (resolved === helper) return true;
|
||||
if (
|
||||
path.basename(resolved) === "mod.rs" &&
|
||||
helper.startsWith(`${path.dirname(resolved)}/`)
|
||||
)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The integration-test targets of a helper's package that include it, as
|
||||
// files. A helper no target is found to include (a layout rustIncludes does
|
||||
// not read) falls back to every target, so a helper change is never left
|
||||
// unrepeated by the scan.
|
||||
export function rustImporters(helper) {
|
||||
const { dir } = rustFile(helper);
|
||||
const tests = `${dir}/tests`;
|
||||
const targets = readdirSync(`${root}${tests}`, { withFileTypes: true })
|
||||
.flatMap((entry) =>
|
||||
entry.isFile() && entry.name.endsWith(".rs")
|
||||
? [`${tests}/${entry.name}`]
|
||||
: entry.isDirectory() &&
|
||||
existsSync(`${root}${tests}/${entry.name}/main.rs`)
|
||||
? [`${tests}/${entry.name}/main.rs`]
|
||||
: [],
|
||||
)
|
||||
.filter((target) => target !== helper)
|
||||
.sort();
|
||||
const importers = targets.filter((target) =>
|
||||
rustIncludes(target, readFileSync(`${root}${target}`, "utf8"), helper),
|
||||
);
|
||||
return {
|
||||
importers: importers.length ? importers : targets,
|
||||
scanned: importers.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Smoke journeys per spec file as Playwright lists them, each line once (a
|
||||
// looped declaration is listed once per iteration). Integration-only specs
|
||||
// list nothing for the smoke project, which Playwright reports as its "No
|
||||
@@ -502,11 +611,16 @@ function select(base = "HEAD^1", head = "HEAD") {
|
||||
added.has(file) ? "" : git("show", `${base}:${file}`);
|
||||
const diff = (file) =>
|
||||
git("diff", "-U0", "--no-color", base, head, "--", file);
|
||||
// Every runnable test of a Rust file, as entries.
|
||||
const wholeRust = (file) =>
|
||||
rustTests(readFileSync(`${root}${file}`, "utf8"))
|
||||
.filter((test) => !test.ignored)
|
||||
.map((test) => `${file}::${test.name}`);
|
||||
for (const runner of runners) {
|
||||
for (const file of files[runner]) {
|
||||
let entries = [file];
|
||||
let note = "whole file";
|
||||
if (runner === "rust") {
|
||||
if (rustWorkspaces[runner]) {
|
||||
const selection = selectRust(
|
||||
diff(file),
|
||||
readFileSync(`${root}${file}`, "utf8"),
|
||||
@@ -515,17 +629,37 @@ function select(base = "HEAD^1", head = "HEAD") {
|
||||
previous: previous(file),
|
||||
},
|
||||
);
|
||||
if (!selection) {
|
||||
if (selection) {
|
||||
entries = selection.names.map((name) => `${file}::${name}`);
|
||||
note =
|
||||
selection.names.length === 0
|
||||
? "excluded (ignored tests only)"
|
||||
: selection.whole
|
||||
? `whole file (${selection.reason}): ${selection.names.length} test(s)`
|
||||
: `${selection.names.length} changed test(s)`;
|
||||
} else if (rustFile(file).kind === "tests") {
|
||||
// A tests/** file without test functions is a helper module that
|
||||
// integration binaries compile in (`mod common;`), so a change to
|
||||
// it can change every test of each binary that includes it: select
|
||||
// those binaries whole. The ignored and infrastructure exclusions
|
||||
// still apply downstream, as for any whole-file selection.
|
||||
const { importers, scanned } = rustImporters(file);
|
||||
entries = importers.flatMap(wholeRust);
|
||||
const names = importers.map(
|
||||
(importer) => rustFile(importer).parts[0],
|
||||
);
|
||||
note =
|
||||
importers.length === 0
|
||||
? "helper with no integration tests to repeat"
|
||||
: `helper for${scanned ? "" : " (no importer found; every integration test)"}: ${names.join(", ")}: ${entries.length} test(s)`;
|
||||
} else {
|
||||
// A src/** file without test functions is the code under test, not
|
||||
// a test file. This is deliberate: the regular lanes build and run
|
||||
// everything that depends on it, and repeating its crate's whole
|
||||
// suite for any source change would not be a changed-test check.
|
||||
rows.push(`| ${cell(file)} | ${runner} | no test functions |`);
|
||||
continue;
|
||||
}
|
||||
entries = selection.names.map((name) => `${file}::${name}`);
|
||||
note =
|
||||
selection.names.length === 0
|
||||
? "excluded (ignored tests only)"
|
||||
: selection.whole
|
||||
? `whole file (${selection.reason}): ${selection.names.length} test(s)`
|
||||
: `${selection.names.length} changed test(s)`;
|
||||
} else if (runner === "playwright") {
|
||||
const listed = journeys.get(file) ?? [];
|
||||
const source = readFileSync(`${root}${file}`, "utf8");
|
||||
@@ -549,6 +683,9 @@ function select(base = "HEAD^1", head = "HEAD") {
|
||||
outputs[runner].push(...entries);
|
||||
rows.push(`| ${cell(file)} | ${runner} | ${cell(note)} |`);
|
||||
}
|
||||
// A helper and a file that includes it, changed together, select the
|
||||
// same tests twice.
|
||||
outputs[runner] = [...new Set(outputs[runner])];
|
||||
const exceeded =
|
||||
Buffer.byteLength(outputs[runner].join(" ")) > MAX_OUTPUT_BYTES
|
||||
? `selection exceeds ${MAX_OUTPUT_BYTES} bytes`
|
||||
@@ -569,7 +706,9 @@ function select(base = "HEAD^1", head = "HEAD") {
|
||||
"",
|
||||
...(rows.length
|
||||
? ["| File | Runner | Selection |", "| --- | --- | --- |", ...rows]
|
||||
: ["No Rust, node:test, Playwright or Flutter test files changed."]),
|
||||
: [
|
||||
"No Rust, desktop Tauri, node:test, Playwright or Flutter test files changed.",
|
||||
]),
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
@@ -678,53 +817,65 @@ export function chooseRust(tests, candidates) {
|
||||
// flags of its selected files, each whole flag once (`--test codec --test
|
||||
// connection`; merging tokens instead gave `--test codec connection`, which
|
||||
// nextest reads as a test-name filter that matches nothing), and the tests
|
||||
// to list in those targets.
|
||||
// to list in those targets. Every entry must belong to one workspace, whose
|
||||
// runner is returned with the groups.
|
||||
export function rustGroups(entries) {
|
||||
const groups = new Map();
|
||||
const workspaces = new Set();
|
||||
for (const entry of entries) {
|
||||
const [, file, path] =
|
||||
/^(crates\/[^:]+\.rs)::((?:\w+::)*\w+)$/.exec(entry) ?? [];
|
||||
if (!file)
|
||||
const [, file, test] = /^([^:]+\.rs)::((?:\w+::)*\w+)$/.exec(entry) ?? [];
|
||||
const place = file && rustFile(file);
|
||||
if (!place)
|
||||
throw new Error(`rust entry must be <file>::<module::test>: ${entry}`);
|
||||
workspaces.add(place.runner);
|
||||
const crate = cratePackage(file);
|
||||
const group = groups.get(crate) ?? { targets: new Map(), tests: [] };
|
||||
const targets = rustTarget(file);
|
||||
if (targets.length === 0) group.every = true;
|
||||
for (const target of targets) group.targets.set(target.join(" "), target);
|
||||
group.tests.push({
|
||||
path,
|
||||
exact: [...rustModulePath(file), path].join("::"),
|
||||
path: test,
|
||||
exact: [...rustModulePath(file), test].join("::"),
|
||||
});
|
||||
groups.set(crate, group);
|
||||
}
|
||||
if (workspaces.size > 1)
|
||||
throw new Error(
|
||||
`rust entries span workspaces: ${[...workspaces].join(", ")}`,
|
||||
);
|
||||
for (const [crate, group] of groups)
|
||||
group.args = [
|
||||
"-p",
|
||||
crate,
|
||||
...(group.every ? [] : [...group.targets.values()].flat()),
|
||||
];
|
||||
return groups;
|
||||
return { groups, runner: [...workspaces][0] };
|
||||
}
|
||||
|
||||
// nextest has no repeat flag, so run the selection once per iteration under
|
||||
// the `flake` profile (.config/nextest.toml: no retries, JUnit per run), one
|
||||
// cargo invocation per package so each builds only the targets its files can
|
||||
// be in. The selection is listed first, by in-file path, and narrowed with
|
||||
// chooseRust to exact (binary, test) pairs; a selection listing no runnable
|
||||
// test is one of ignored or infrastructure-backed tests, not a failure.
|
||||
// the `flake` profile (the workspace's .config/nextest.toml: no retries, JUnit
|
||||
// per run), one cargo invocation per package so each builds only the targets
|
||||
// its files can be in. The selection is listed first, by in-file path, and
|
||||
// narrowed with chooseRust to exact (binary, test) pairs; a selection listing
|
||||
// no runnable test is one of ignored or infrastructure-backed tests, not a
|
||||
// failure. The Tauri workspace is addressed by its manifest; the root one is
|
||||
// cargo's default.
|
||||
function repeatRust(entries, iterations) {
|
||||
const groups = rustGroups(entries);
|
||||
const { groups, runner } = rustGroups(entries);
|
||||
const manifest = rustWorkspaces[runner];
|
||||
const cargoArgs = runner === "rust" ? [] : ["--manifest-path", manifest];
|
||||
const runs = [];
|
||||
for (const group of groups.values()) {
|
||||
const suffixes = [...new Set(group.tests.map(({ path }) => path))]
|
||||
.map((path) => `test(/(^|::)${path}$/)`)
|
||||
.join(" | ");
|
||||
const listed = run("cargo", [
|
||||
"nextest",
|
||||
"list",
|
||||
...cargoArgs,
|
||||
...group.args,
|
||||
"-E",
|
||||
`(${suffixes}) & !(${infraTests})`,
|
||||
rustFilterset(
|
||||
runner,
|
||||
group.tests.map(({ path }) => path),
|
||||
),
|
||||
"--message-format",
|
||||
"json",
|
||||
]);
|
||||
@@ -756,8 +907,12 @@ function repeatRust(entries, iterations) {
|
||||
}
|
||||
// nextest keeps its store under the workspace root's target/nextest, not
|
||||
// CARGO_TARGET_DIR; the `flake` profile writes its JUnit report there.
|
||||
const junit = `${root}target/nextest/flake/junit.xml`;
|
||||
const dir = resultsDir("rust");
|
||||
const junit = path.join(
|
||||
root,
|
||||
path.dirname(manifest),
|
||||
"target/nextest/flake/junit.xml",
|
||||
);
|
||||
const dir = resultsDir(runner);
|
||||
for (let i = 1; i <= iterations; i += 1) {
|
||||
const statuses = new Map();
|
||||
let ok = true;
|
||||
@@ -769,6 +924,7 @@ function repeatRust(entries, iterations) {
|
||||
[
|
||||
"nextest",
|
||||
"run",
|
||||
...cargoArgs,
|
||||
"--profile",
|
||||
"flake",
|
||||
...group.args,
|
||||
@@ -790,7 +946,10 @@ function repeatRust(entries, iterations) {
|
||||
runs.push({ ok, statuses });
|
||||
progress(i, iterations, ok);
|
||||
}
|
||||
report("Flake check (Rust)", runs);
|
||||
report(
|
||||
runner === "rust" ? "Flake check (Rust)" : "Flake check (Desktop Tauri)",
|
||||
runs,
|
||||
);
|
||||
}
|
||||
|
||||
// node:test has no repeat flag either. Each file runs alone, so a failure is
|
||||
@@ -931,9 +1090,12 @@ function main() {
|
||||
if (command === "select" && args.length <= 2) return select(...args);
|
||||
if (command === "playwright-summary" && args.length === 2)
|
||||
return playwrightSummary(...args);
|
||||
const repeat = { rust: repeatRust, node: repeatNode, dart: repeatDart }[
|
||||
command
|
||||
];
|
||||
const repeat = {
|
||||
rust: repeatRust,
|
||||
tauri: repeatRust,
|
||||
node: repeatNode,
|
||||
dart: repeatDart,
|
||||
}[command];
|
||||
if (repeat) {
|
||||
const iterations = Number(
|
||||
args
|
||||
@@ -945,7 +1107,7 @@ function main() {
|
||||
return repeat(entries, iterations);
|
||||
}
|
||||
throw new Error(
|
||||
"usage: flake-check select [base] [head] | rust|node|dart [--iterations=N] <entry…> | playwright-summary <report.json> <title>",
|
||||
"usage: flake-check select [base] [head] | rust|tauri|node|dart [--iterations=N] <entry…> | playwright-summary <report.json> <title>",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
chooseRust,
|
||||
classify,
|
||||
@@ -15,10 +19,15 @@ import {
|
||||
parseJunit,
|
||||
playwrightRepetitions,
|
||||
playwrightShards,
|
||||
runners,
|
||||
rustFilterset,
|
||||
rustGroups,
|
||||
rustImporters,
|
||||
rustIncludes,
|
||||
rustModulePath,
|
||||
rustTarget,
|
||||
rustTests,
|
||||
rustWorkspaces,
|
||||
selectRust,
|
||||
selectTests,
|
||||
testBodies,
|
||||
@@ -26,6 +35,47 @@ import {
|
||||
} from "./flake-check.mjs";
|
||||
|
||||
const read = (path) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
const root = fileURLToPath(new URL("../", import.meta.url));
|
||||
const git = (args, env = {}) =>
|
||||
execFileSync("git", ["-c", "core.hooksPath=/dev/null", ...args], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
// `select` over a synthetic head tree: HEAD's tree with a comment appended to
|
||||
// each given file, so only those files differ and no test body changes. The
|
||||
// outputs are keyed per runner as the workflow reads them.
|
||||
function selectAppending(...files) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "flake-select-"));
|
||||
try {
|
||||
const env = { GIT_INDEX_FILE: join(dir, "index") };
|
||||
git(["read-tree", "HEAD"], env);
|
||||
for (const file of files) {
|
||||
const blob = execFileSync("git", ["hash-object", "-w", "--stdin"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
input: `${git(["show", `HEAD:${file}`])}// flake-check probe\n`,
|
||||
}).trim();
|
||||
git(["update-index", "--cacheinfo", `100644,${blob},${file}`], env);
|
||||
}
|
||||
const tree = git(["write-tree"], env).trim();
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[join(root, "scripts/flake-check.mjs"), "select", "HEAD", tree],
|
||||
{ cwd: root, encoding: "utf8" },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const outputs = Object.fromEntries(
|
||||
[...result.stdout.matchAll(/^(\w+)=(.*)$/gm)].map(([, key, value]) => [
|
||||
key,
|
||||
value.split(" ").filter(Boolean),
|
||||
]),
|
||||
);
|
||||
return { summary: result.stdout, outputs };
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
// A hunk writing new-side lines start..end; by default it removed nothing.
|
||||
const hunk = (start, end, removed = { start: start + 1, end: start }) => ({
|
||||
start,
|
||||
@@ -40,6 +90,10 @@ test("classification follows each runner's test file layout", () => {
|
||||
for (const [file, runner] of [
|
||||
["crates/buzz-core/src/filter.rs", "rust"],
|
||||
["crates/buzz-test-client/tests/e2e_relay.rs", "rust"],
|
||||
["desktop/src-tauri/src/lib.rs", "tauri"],
|
||||
["desktop/src-tauri/tests/rodio_mixer_diagnostic.rs", "tauri"],
|
||||
["desktop/src-tauri/crates/buzz-terminal/src/lib.rs", "tauri"],
|
||||
["desktop/src-tauri/crates/buzz-terminal/tests/latency.rs", "tauri"],
|
||||
["desktop/src/features/channels/lib/channelDescription.test.mjs", "node"],
|
||||
["desktop/src/shared/ui/thing.jsdom-test.mjs", "node"],
|
||||
["desktop/scripts/demo-build-config.test.mjs", "node"],
|
||||
@@ -50,7 +104,9 @@ test("classification follows each runner's test file layout", () => {
|
||||
for (const file of [
|
||||
"crates/buzz-core/Cargo.toml",
|
||||
"crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/src/main.rs",
|
||||
"desktop/src-tauri/src/lib.rs",
|
||||
"desktop/src-tauri/build.rs",
|
||||
"desktop/src-tauri/tests/fixtures/x.rs",
|
||||
"desktop/src-tauri/crates/buzz-terminal/Cargo.toml",
|
||||
"desktop/src/features/channels/lib/channelDescription.ts",
|
||||
"desktop/tests/e2e/helpers/animations.ts",
|
||||
"desktop/tests/e2e/typing-latency.perf.ts",
|
||||
@@ -64,6 +120,9 @@ test("classification follows each runner's test file layout", () => {
|
||||
"crates/buzz-core/src/$(id).rs",
|
||||
])
|
||||
assert.equal(classify(file), undefined, file);
|
||||
// Each Rust runner names the cargo workspace its lane repeats.
|
||||
assert.deepEqual(Object.keys(rustWorkspaces), ["rust", "tauri"]);
|
||||
assert.ok(runners.includes("tauri"));
|
||||
});
|
||||
|
||||
test("-U0 hunks map to new-side ranges, with deletions attached to the preceding line and their old-side lines kept", () => {
|
||||
@@ -230,6 +289,12 @@ test("same-named Rust tests in different modules are distinct, and a hunk select
|
||||
["crates/buzz-test-client/tests/e2e_relay.rs", []],
|
||||
["crates/x/tests/suite/main.rs", []],
|
||||
["crates/x/tests/suite/helpers.rs", ["helpers"]],
|
||||
["desktop/src-tauri/src/app_state.rs", ["app_state"]],
|
||||
["desktop/src-tauri/tests/rodio_mixer_diagnostic.rs", []],
|
||||
[
|
||||
"desktop/src-tauri/crates/buzz-terminal/src/grid/cell.rs",
|
||||
["grid", "cell"],
|
||||
],
|
||||
])
|
||||
assert.deepEqual(rustModulePath(file), modules, file);
|
||||
// Listed candidates: the predicted name wins; without it every suffix
|
||||
@@ -268,12 +333,21 @@ test("Rust files build only the targets they can be part of, merged per package
|
||||
["crates/buzz-admin/src/main.rs", [["--bins"]]],
|
||||
["crates/buzz-test-client/tests/e2e_relay.rs", [["--test", "e2e_relay"]]],
|
||||
["crates/buzz-agent/tests/common/mod.rs", []],
|
||||
["desktop/src-tauri/src/lib.rs", [["--lib"], ["--bins"]]],
|
||||
[
|
||||
"desktop/src-tauri/tests/rodio_mixer_diagnostic.rs",
|
||||
[["--test", "rodio_mixer_diagnostic"]],
|
||||
],
|
||||
[
|
||||
"desktop/src-tauri/crates/buzz-terminal/tests/latency.rs",
|
||||
[["--test", "latency"]],
|
||||
],
|
||||
])
|
||||
assert.deepEqual(rustTarget(file), flags, file);
|
||||
// Two integration tests keep two `--test` flags: `--test codec connection`
|
||||
// would make nextest filter on a test named "connection" and repeat nothing.
|
||||
const args = (...entries) =>
|
||||
[...rustGroups(entries).values()].map((group) => group.args);
|
||||
[...rustGroups(entries).groups.values()].map((group) => group.args);
|
||||
assert.deepEqual(
|
||||
args(
|
||||
"crates/buzz-audio-client/tests/codec.rs::roundtrip",
|
||||
@@ -301,6 +375,167 @@ test("Rust files build only the targets they can be part of, merged per package
|
||||
["-p", "buzz-admin", "--bins"],
|
||||
],
|
||||
);
|
||||
// The Tauri workspace's packages are addressed by its manifest, and never
|
||||
// mixed into a root-workspace invocation.
|
||||
const tauri = rustGroups([
|
||||
"desktop/src-tauri/tests/rodio_mixer_diagnostic.rs::single_queue_serializes_sources",
|
||||
"desktop/src-tauri/crates/buzz-terminal/tests/latency.rs::g3_renderer_acquire_stays_within_frame_budget",
|
||||
]);
|
||||
assert.equal(tauri.runner, "tauri");
|
||||
assert.equal(rustWorkspaces[tauri.runner], "desktop/src-tauri/Cargo.toml");
|
||||
assert.deepEqual(
|
||||
[...tauri.groups.values()].map((group) => group.args),
|
||||
[
|
||||
["-p", "buzz-desktop", "--test", "rodio_mixer_diagnostic"],
|
||||
["-p", "buzz-terminal", "--test", "latency"],
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
rustGroups(["crates/buzz-core/src/filter.rs::tests::a"]).runner,
|
||||
"rust",
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
rustGroups([
|
||||
"crates/buzz-core/src/filter.rs::tests::a",
|
||||
"desktop/src-tauri/src/lib.rs::tests::b",
|
||||
]),
|
||||
/span workspaces/,
|
||||
);
|
||||
assert.throws(
|
||||
() => rustGroups(["desktop/src/x.rs::tests::a"]),
|
||||
/rust entry must be/,
|
||||
);
|
||||
// The root workspace's listing excludes its Postgres-backed tests by name
|
||||
// and binary; nextest rejects `binary(/^postgres_/)` in the Tauri
|
||||
// workspace, where no such binary exists, so that listing carries no
|
||||
// exclusion.
|
||||
assert.equal(
|
||||
rustFilterset("rust", ["tests::a", "b", "tests::a"]),
|
||||
"(test(/(^|::)tests::a$/) | test(/(^|::)b$/)) & !(test(/postgres_tests::/) | binary(/^postgres_/) | test(/(^|::)external_infra[^:]*::/))",
|
||||
);
|
||||
assert.equal(rustFilterset("tauri", ["a"]), "(test(/(^|::)a$/))");
|
||||
assert.doesNotMatch(rustFilterset("tauri", ["a"]), /binary\(/);
|
||||
});
|
||||
|
||||
test("a helper under tests/ selects the integration binaries that include it", () => {
|
||||
const helper = "crates/x/tests/common/mod.rs";
|
||||
const includes = (target, ...lines) =>
|
||||
rustIncludes(target, `${lines.join("\n")}\n`, helper);
|
||||
// `mod common;` in a crate root, in any form; not in a comment or string.
|
||||
assert.ok(
|
||||
includes("crates/x/tests/a.rs", "mod common;", "#[test] fn t() {}"),
|
||||
);
|
||||
assert.ok(includes("crates/x/tests/a.rs", "pub(crate) mod common ;"));
|
||||
assert.ok(!includes("crates/x/tests/a.rs", "// uses mod common; elsewhere"));
|
||||
assert.ok(!includes("crates/x/tests/a.rs", 'const S: &str = "mod common;";'));
|
||||
assert.ok(!includes("crates/x/tests/a.rs", "mod commonplace;", "mod other;"));
|
||||
// `#[path]` reaching the file itself or its directory's mod.rs.
|
||||
assert.ok(
|
||||
includes("crates/x/tests/a.rs", '#[path = "common/mod.rs"]', "mod shared;"),
|
||||
);
|
||||
assert.ok(
|
||||
includes(
|
||||
"crates/x/tests/a.rs",
|
||||
'#[path="../tests/common/mod.rs"] mod shared;',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!includes("crates/x/tests/a.rs", '#[path = "other/mod.rs"]', "mod shared;"),
|
||||
);
|
||||
assert.ok(
|
||||
rustIncludes(
|
||||
"crates/x/tests/a.rs",
|
||||
'#[path = "common/mod.rs"] mod shared;\n',
|
||||
"crates/x/tests/common/util.rs",
|
||||
),
|
||||
);
|
||||
// A tests/<dir>/main.rs target owns every file under its directory.
|
||||
assert.ok(
|
||||
rustIncludes(
|
||||
"crates/x/tests/suite/main.rs",
|
||||
"",
|
||||
"crates/x/tests/suite/util.rs",
|
||||
),
|
||||
);
|
||||
assert.ok(!rustIncludes("crates/x/tests/suite/main.rs", "", helper));
|
||||
assert.ok(
|
||||
rustIncludes(
|
||||
"crates/x/tests/suite/main.rs",
|
||||
'#[path = "../common/mod.rs"]\nmod common;\n',
|
||||
helper,
|
||||
),
|
||||
);
|
||||
// The real importers of buzz-agent's shared harness: the files declaring
|
||||
// `mod common;`, not the suites that do not.
|
||||
assert.deepEqual(rustImporters("crates/buzz-agent/tests/common/mod.rs"), {
|
||||
importers: [
|
||||
"crates/buzz-agent/tests/anthropic_continuation.rs",
|
||||
"crates/buzz-agent/tests/fake_llm.rs",
|
||||
"crates/buzz-agent/tests/permission_boundary.rs",
|
||||
"crates/buzz-agent/tests/regressions.rs",
|
||||
],
|
||||
scanned: true,
|
||||
});
|
||||
for (const importer of rustImporters("crates/buzz-agent/tests/common/mod.rs")
|
||||
.importers)
|
||||
assert.match(read(`../${importer}`), /^mod common;$/m, importer);
|
||||
assert.doesNotMatch(
|
||||
read("../crates/buzz-agent/tests/golden_transcripts.rs"),
|
||||
/^mod common;$/m,
|
||||
);
|
||||
});
|
||||
|
||||
test("select, through its entry point, repeats a changed Tauri test and the binaries including a changed helper", () => {
|
||||
// Reviewer probes on #8613: before, both produced an empty selection and
|
||||
// every repeat lane skipped.
|
||||
const tauri = selectAppending(
|
||||
"desktop/src-tauri/tests/rodio_mixer_diagnostic.rs",
|
||||
);
|
||||
assert.match(
|
||||
tauri.summary,
|
||||
/\| desktop\/src-tauri\/tests\/rodio_mixer_diagnostic\.rs \| tauri \| whole file \(shared code changed\): 3 test\(s\) \|/,
|
||||
);
|
||||
assert.deepEqual(tauri.outputs.rust, []);
|
||||
assert.deepEqual(
|
||||
tauri.outputs.tauri,
|
||||
rustTests(read("../desktop/src-tauri/tests/rodio_mixer_diagnostic.rs")).map(
|
||||
(test) =>
|
||||
`desktop/src-tauri/tests/rodio_mixer_diagnostic.rs::${test.name}`,
|
||||
),
|
||||
);
|
||||
assert.equal(tauri.outputs.tauri.length, 3);
|
||||
|
||||
const helper = selectAppending("crates/buzz-agent/tests/common/mod.rs");
|
||||
assert.match(
|
||||
helper.summary,
|
||||
/\| crates\/buzz-agent\/tests\/common\/mod\.rs \| rust \| helper for: anthropic_continuation, fake_llm, permission_boundary, regressions: \d+ test\(s\) \|/,
|
||||
);
|
||||
const binaries = new Set(
|
||||
helper.outputs.rust.map((entry) => entry.split("::")[0]),
|
||||
);
|
||||
assert.deepEqual([...binaries].sort(), [
|
||||
"crates/buzz-agent/tests/anthropic_continuation.rs",
|
||||
"crates/buzz-agent/tests/fake_llm.rs",
|
||||
"crates/buzz-agent/tests/permission_boundary.rs",
|
||||
"crates/buzz-agent/tests/regressions.rs",
|
||||
]);
|
||||
assert.ok(helper.outputs.rust.length > 0);
|
||||
// Changed together, the helper and an importer select each test once.
|
||||
const both = selectAppending(
|
||||
"crates/buzz-agent/tests/common/mod.rs",
|
||||
"crates/buzz-agent/tests/fake_llm.rs",
|
||||
);
|
||||
assert.deepEqual(both.outputs.rust, [...new Set(both.outputs.rust)]);
|
||||
assert.deepEqual(both.outputs.rust.length, helper.outputs.rust.length);
|
||||
// A src/** file without test functions still selects nothing: the regular
|
||||
// lanes run what depends on it.
|
||||
const source = selectAppending("crates/buzz-core/src/lib.rs");
|
||||
assert.match(
|
||||
source.summary,
|
||||
/\| crates\/buzz-core\/src\/lib\.rs \| rust \| no test functions \|/,
|
||||
);
|
||||
assert.deepEqual(source.outputs.rust, []);
|
||||
});
|
||||
|
||||
test("Rust selection: a hunk inside a test selects it, elsewhere selects every runnable test; ignored tests never run", () => {
|
||||
@@ -614,16 +849,39 @@ test("repeat invocations mirror the lanes they stand in for", () => {
|
||||
`scripts/mobile-test-shard.sh \${{ matrix.shard }} 3 ${dartDefine}`,
|
||||
),
|
||||
);
|
||||
assert.match(
|
||||
read("../.config/nextest.toml"),
|
||||
/^\[profile\.flake\]\n(?:#.*\n)*retries = 0\nfail-fast = false\n\n\[profile\.flake\.junit\]\npath = "junit\.xml"\n/m,
|
||||
);
|
||||
// nextest reads its config from the workspace root, so each Rust workspace
|
||||
// carries the flake profile.
|
||||
for (const config of [
|
||||
"../.config/nextest.toml",
|
||||
"../desktop/src-tauri/.config/nextest.toml",
|
||||
])
|
||||
assert.match(
|
||||
read(config),
|
||||
/^\[profile\.flake\]\n(?:#.*\n)*retries = 0\nfail-fast = false\n\n\[profile\.flake\.junit\]\npath = "junit\.xml"\n/m,
|
||||
config,
|
||||
);
|
||||
const flake = read("../.github/workflows/_ci-flake.yml");
|
||||
for (const runner of ["rust", "node", "dart"])
|
||||
for (const runner of ["rust", "tauri", "node", "dart"])
|
||||
assert.ok(
|
||||
flake.includes(`flake-check.mjs ${runner} --iterations=10 $SELECTED`),
|
||||
runner,
|
||||
);
|
||||
// The Tauri lane builds what the Desktop Tauri core lane does: the same
|
||||
// workspace cache, system packages and sidecar stubs.
|
||||
const desktop = read("../.github/workflows/_ci-desktop.yml");
|
||||
const packages = (text) =>
|
||||
/apt-get install -y --no-install-recommends \\\n((?: {12}\S+(?: \\)?\n)+)/.exec(
|
||||
text,
|
||||
)?.[1];
|
||||
assert.ok(packages(desktop), "Desktop Tauri apt packages");
|
||||
assert.equal(packages(flake), packages(desktop));
|
||||
assert.match(
|
||||
flake,
|
||||
/flake-tauri:\n(?: {4}.*\n)*? {6}- uses: Swatinem\/rust-cache@\w+\n {8}with:\n {10}workspaces: desktop\/src-tauri\n {10}shared-key: desktop-core\n {10}save-if: false\n/,
|
||||
);
|
||||
assert.match(desktop, /- lane: core\n {12}cache: desktop-core\n/);
|
||||
assert.ok(flake.includes("run: just _ensure-sidecar-stubs"));
|
||||
assert.ok(read("../Justfile").includes("\n_ensure-sidecar-stubs:\n"));
|
||||
assert.ok(flake.includes("--repeat-each=10 --retries=0"));
|
||||
// The lanes are advisory: ci.yml calls the workflow on pull requests only,
|
||||
// and no required gate depends on it.
|
||||
|
||||
Reference in New Issue
Block a user