Files
buzz/scripts/ci-selection.test.mjs
Tom Brow d79446316b fix(mobile): support DeviceHub in mobile-dev (#8404)
`just mobile-dev` fails on Xcode 27 machines that have DeviceHub instead
of Simulator. Resolve the simulator frontend inside the selected Xcode
installation: DeviceHub for Xcode 27, or Simulator for Xcode 26. Launch
by full path so an older installed or running app cannot determine the
choice.

This extracts the still-needed DeviceHub portion of Wes's
[#8330](https://github.com/block/buzz/pull/8330). The CocoaPods
deployment-target work already landed in
[#8379](https://github.com/block/buzz/pull/8379); this PR does not
change Podfile or its lockfile.

CI and pre-push mobile selection include the shared Justfile and mobile
recipe regression script, so edits to either run the regression in the
mobile gate.

### Validation

- Nine production-recipe checks pass, covering side-by-side Xcode 26/27
selection with spaces in paths, missing apps, launch failure, and
gateway argument forwarding. Mutations reject unqualified Launch
Services lookup and removal of DeviceHub selection.
- All 39 tests of the pinned paths-filter action pass, including the
Justfile and recipe-test selection cases. Mobile formatting, analysis,
and the full mobile suite pass.
- Native macOS smoke checks exercised launch from the selected Xcode
installation and reached the Flutter boundary. Flutter and identity
setup were stubbed, so no app was built or installed.
- The initial full local CI gate stopped on an unchanged ACP
signal-cancellation test; that exact test passed in isolation. The
initial pre-push run also hit missing dependencies and two unchanged
desktop agent-discovery tests. Desktop lint and TypeScript checks passed
after installing pinned dependencies. Publishing used the authorized
local-hook exception.
- Current-head
[CI](https://github.com/block/buzz/actions/runs/38963831198) passed,
including all nine recipe cases and native Swift validation. Owner
approval and Codex code/security reviews are complete with no unresolved
findings.


buzz-review-completed

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-16 14:06:53 -07:00

367 lines
11 KiB
JavaScript

import assert from "node:assert/strict";
import { execFile, execFileSync } from "node:child_process";
import {
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { after, test } from "node:test";
import { promisify } from "node:util";
const workflow = readFileSync(
new URL("../.github/workflows/ci.yml", import.meta.url),
"utf8",
);
const scratch = mkdtempSync(join(tmpdir(), "buzz-ci-selection-"));
after(() => rmSync(scratch, { recursive: true, force: true }));
const actionSha = workflow.match(/uses: dorny\/paths-filter@([a-f0-9]{40})/)[1];
const filters = workflow
.match(/ {10}filters: \|\n([\s\S]*?)(?= {6}- name:)/)[1]
.split("\n")
.map((line) => line.slice(12))
.join("\n");
// GitHub-hosted runners prepare pinned actions beside RUNNER_TEMP before any
// steps run. Reuse that bundle, without another download in the selection gate.
// Locally, point PATHS_FILTER_ACTION at dist/index.js from the pinned action.
const actionPath =
process.env.PATHS_FILTER_ACTION ||
(process.env.RUNNER_TEMP &&
join(
process.env.RUNNER_TEMP,
"..",
"_actions",
"dorny",
"paths-filter",
actionSha,
"dist",
"index.js",
));
assert.ok(
actionPath && existsSync(actionPath),
`Set PATHS_FILTER_ACTION to the local dist/index.js from dorny/paths-filter@${actionSha}`,
);
async function select(paths, pullRequest = false, apiStatus = 200) {
const repo = mkdtempSync(join(scratch, "repo-"));
const git = (...args) =>
execFileSync("git", args, { cwd: repo, stdio: "pipe", timeout: 10000 });
git("init", "-q");
// Fixture repositories must not inherit developer machine hooks.
mkdirSync(join(repo, "empty-hooks"));
git("config", "core.hooksPath", join(repo, "empty-hooks"));
git(
"-c",
"user.name=CI fixture",
"-c",
"user.email=ci@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-q",
"--allow-empty",
"-m",
"fixture",
"-s",
);
for (const path of paths) {
mkdirSync(dirname(join(repo, path)), { recursive: true });
writeFileSync(join(repo, path), "fixture\n");
}
git("add", ".");
let server;
let apiUrl;
const eventPath = join(scratch, `event-${repo.split("/").pop()}.json`);
if (pullRequest) {
const commit = (message) =>
git(
"-c",
"user.name=CI fixture",
"-c",
"user.email=ci@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-qm",
message,
"-s",
);
const base = git("rev-parse", "HEAD").toString().trim();
commit("PR documentation or code");
const head = git("rev-parse", "HEAD").toString().trim();
git("checkout", "-qb", "upstream", base);
const upstreamPath = "desktop/src/upstream-only.ts";
mkdirSync(dirname(join(repo, upstreamPath)), { recursive: true });
writeFileSync(join(repo, upstreamPath), "upstream change\n");
git("add", upstreamPath);
commit("Unrelated upstream desktop change");
git(
"-c",
"user.name=CI fixture",
"-c",
"user.email=ci@example.com",
"-c",
"commit.gpgsign=false",
"merge",
"--no-ff",
"-m",
"Synthetic merge",
head,
);
git("checkout", "--detach");
writeFileSync(
eventPath,
JSON.stringify({
pull_request: {
number: 7809,
changed_files: paths.length,
base: { sha: base },
head: { sha: head },
},
repository: { default_branch: "main" },
}),
);
// Serve the PR file list, which intentionally excludes the newer base's code.
server = createServer((request, response) => {
assert.equal(
request.url,
"/repos/block/buzz/pulls/7809/files?per_page=100",
);
response.setHeader("Content-Type", "application/json");
if (apiStatus !== 200) {
response.writeHead(apiStatus);
response.end(JSON.stringify({ message: "Fixture access denied" }));
return;
}
response.end(
JSON.stringify(
paths
.slice(0, 3000)
.map((filename) => ({ filename, status: "added" })),
),
);
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
apiUrl = `http://127.0.0.1:${server.address().port}`;
}
const output = join(repo, "action-output");
writeFileSync(output, "");
try {
await promisify(execFile)(process.execPath, [actionPath], {
cwd: repo,
encoding: "utf8",
timeout: 30000,
env: {
...process.env,
INPUT_BASE: pullRequest ? "" : "HEAD",
INPUT_FILTERS: filters,
// Resolve the workflow's token expression to a fixture token. Removing
// it must reproduce the contaminated git comparison in the PR fixture.
INPUT_TOKEN:
pullRequest && /token: \$\{\{ github\.token \}\}/.test(workflow)
? "fixture-token"
: "",
INPUT_REF: "",
GITHUB_EVENT_NAME: pullRequest ? "pull_request" : "",
GITHUB_EVENT_PATH: pullRequest ? eventPath : "",
GITHUB_REPOSITORY: "block/buzz",
GITHUB_API_URL: apiUrl || "https://api.github.com",
GITHUB_OUTPUT: output,
"INPUT_PREDICATE-QUANTIFIER":
workflow.match(/predicate-quantifier: ['"]?([\w-]+)/)?.[1] ?? "some",
},
});
} finally {
if (server) await new Promise((resolve) => server.close(resolve));
}
const rawOutputs = Object.fromEntries(
[
...readFileSync(output, "utf8").matchAll(
/^(rust|desktop|desktop-rust|web|mobile)<<([^\n]+)\n(true|false)\n\2/gm,
),
].map(([, key, , value]) => [key, value]),
);
const selectedOutput = join(repo, "selected-output");
await promisify(execFile)(
process.execPath,
[new URL("./ci-runtime-selection.mjs", import.meta.url).pathname],
{
env: {
...process.env,
FILTER_OUTPUTS: JSON.stringify(rawOutputs),
GITHUB_EVENT_NAME: pullRequest ? "pull_request" : "push",
GITHUB_EVENT_PATH: eventPath,
GITHUB_OUTPUT: selectedOutput,
},
timeout: 10000,
},
);
return Object.fromEntries(
readFileSync(selectedOutput, "utf8")
.trim()
.split("\n")
.map((line) => line.split("=")),
);
}
const scenarios = [
[
"mobile source and tests",
[
"mobile/lib/features/age_gate/age_signal_provider.dart",
"mobile/test/features/age_gate/age_signal_provider_test.dart",
],
["mobile"],
],
["mobile lockfile", ["mobile/pubspec.lock"], ["mobile"]],
["mobile release script", ["scripts/mobile-release.sh"], ["mobile"]],
[
"mobile gateway recipe tests",
["scripts/test-mobile-gateway-recipes.sh"],
["mobile"],
],
["desktop", ["desktop/src/main.tsx"], ["desktop"]],
["Tauri", ["desktop/src-tauri/src/main.rs"], ["desktop", "desktop-rust"]],
["relay", ["crates/buzz-relay/src/main.rs"], ["rust"]],
["migration", ["migrations/123.sql"], ["rust"]],
[
"shared workflow",
[".github/workflows/ci.yml"],
["desktop", "mobile", "rust"],
],
[
"desktop workflow",
[".github/workflows/_ci-desktop.yml"],
["desktop", "mobile", "rust"],
],
["Justfile", ["Justfile"], ["desktop", "mobile", "rust"]],
["CI apt retry", ["scripts/ci-apt-retry.sh"], ["desktop", "rust"]],
["integration services", ["docker-compose.yml"], ["rust"]],
["CI integration images", ["docker-compose.ci.yml"], ["rust"]],
[
"mixed mobile and relay",
["mobile/lib/main.dart", "crates/buzz-core/src/lib.rs"],
["rust", "mobile"],
],
[
"mixed mobile and desktop",
["mobile/lib/main.dart", "desktop/src/main.tsx"],
["desktop", "mobile"],
],
["web", ["web/src/main.tsx"], ["web"]],
["documentation", ["README.md"], []],
[
"incident documentation",
["CONTEXT.md", "docs/mobile-push-suppression.md", "VISION_MOBILE.md"],
[],
],
[
"nested docs directory",
["docs/mobile/design.md", "docs/nips/NIP-FI.md"],
[],
],
["crate Markdown", ["crates/buzz-cli/README.md"], ["rust"]],
["desktop Markdown", ["desktop/README.md"], ["desktop"]],
[
"Tauri Markdown",
["desktop/src-tauri/README.md"],
["desktop", "desktop-rust"],
],
["web Markdown", ["web/docs/design.md"], ["web"]],
["mobile Markdown", ["mobile/test/README.md"], ["mobile"]],
["schema Markdown", ["schema/README.md"], ["rust"]],
["migration Markdown", ["migrations/README.md"], ["rust"]],
[
"mixed documentation and desktop",
["VISION_MOBILE.md", "docs/mobile/design.md", "desktop/src/main.tsx"],
["desktop"],
],
[
"mixed documentation and relay",
["docs/desktop/design.md", "crates/buzz-relay/src/lib.rs"],
["rust"],
],
["embedded ACP prompt", ["crates/buzz-acp/src/base_prompt.md"], ["rust"]],
[
"embedded Tauri skill",
["desktop/src-tauri/src/managed_agents/nest_skill.md"],
["desktop", "desktop-rust"],
],
];
for (const [name, paths, expected] of scenarios) {
test(`real paths-filter: ${name}`, async () => {
const outputs = await select(paths);
assert.equal(Object.keys(outputs).length, 5);
assert.deepEqual(
Object.keys(outputs)
.filter((key) => outputs[key] === "true")
.sort(),
expected.sort(),
);
});
}
for (const [name, paths, expected] of [
[
"docs-only",
["CONTEXT.md", "docs/mobile-push-suppression.md", "VISION_MOBILE.md"],
[],
],
[
"mixed mobile and Markdown",
["VISION_MOBILE.md", "mobile/lib/main.dart"],
["mobile"],
],
[
"mixed desktop and Markdown",
["CONTEXT.md", "desktop/src/main.tsx"],
["desktop"],
],
]) {
test(`PR file list ignores newer base changes: ${name}`, async () => {
const outputs = await select(paths, true);
assert.equal(Object.keys(outputs).length, 5);
assert.deepEqual(
Object.keys(outputs)
.filter((key) => outputs[key] === "true")
.sort(),
expected.sort(),
);
});
}
test("workflow consumes guarded selection outputs", () => {
const outputs = workflow.match(/ {4}outputs:\n([\s\S]*?) {4}steps:/)[1];
for (const key of ["rust", "desktop", "desktop-rust", "web", "mobile"]) {
assert.ok(outputs.includes(`steps.selection.outputs.${key}`));
}
assert.match(
workflow,
/id: selection\n {8}env:\n {10}FILTER_OUTPUTS: \$\{\{ toJSON\(steps.filter.outputs\) \}\}\n {8}run: node scripts\/ci-runtime-selection.mjs/,
);
});
for (const count of [2999, 3000, 3001]) {
test(`PR file-list ceiling: ${count} changed files`, async () => {
const paths = Array.from(
{ length: Math.min(count, 3000) },
(_, i) => `docs/file-${i}.md`,
);
if (count > 3000) paths.push("desktop/src/omitted-by-api.ts");
const outputs = await select(paths, true);
for (const value of Object.values(outputs)) {
assert.equal(value, count >= 3000 ? "true" : "false");
}
});
}
test("PR API denial fails path selection instead of reporting no changes", async () => {
await assert.rejects(select(["README.md"], true, 403), (error) => {
assert.equal(error.code, 1);
assert.match(error.stdout + error.stderr, /Fixture access denied/);
return true;
});
});