diff --git a/.github/workflows/_ci-flake.yml b/.github/workflows/_ci-flake.yml index f2b4f8c23e..2ab3542e4b 100644 --- a/.github/workflows/_ci-flake.yml +++ b/.github/workflows/_ci-flake.yml @@ -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] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d020a7f9a..95260d2ed5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/desktop/src-tauri/.config/nextest.toml b/desktop/src-tauri/.config/nextest.toml new file mode 100644 index 0000000000..d398fdc2ea --- /dev/null +++ b/desktop/src-tauri/.config/nextest.toml @@ -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" diff --git a/scripts/flake-check.mjs b/scripts/flake-check.mjs index fcb1b8a27a..224296c4c0 100644 --- a/scripts/flake-check.mjs +++ b/scripts/flake-check.mjs @@ -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/.rs or tests//main.rs) +// compiles a helper file under tests/ into its binary: a main.rs owns its +// directory, and any target's source declares `mod ;` 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 ::: ${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] | playwright-summary ", + "usage: flake-check select [base] [head] | rust|tauri|node|dart [--iterations=N] <entry…> | playwright-summary <report.json> <title>", ); } diff --git a/scripts/flake-check.test.mjs b/scripts/flake-check.test.mjs index fa868c993a..439b76d066 100644 --- a/scripts/flake-check.test.mjs +++ b/scripts/flake-check.test.mjs @@ -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.