mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
e832325e87
* refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1) The shared src/utils closure that blocked the platform-family moves lands on declared owners: generic host mechanics form a new private @agent-device/host-kit package between kernel and capture-kit, and capture-kit keeps capture, snapshot, and recording behavior, depending on host-kit for the mechanics it needs. tar-stream and yauzl move with the archive code. Every seam's exported subpaths are pinned in package-boundaries.test.ts, the layering model ranks the new zone, R13's allow-list names it, and each seam carries an exact eager-closure row. ADR-0019's substrate amendment describes the layout. Tests that mocked two of the moved modules separately became duplicate same-seam vi.mock factories, where the second silently replaced the first; those are merged, and the mocks that production code reaches past are pinned at their injection points instead. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * refactor(host-kit): one narrow capability port per export The four technical barrels (exec/fs/values/request) grouped by category rather than by capability, so a consumer needing one mechanic evaluated unrelated ones. Each export is now a single capability over the host machine: command, process, diagnostics, retry, archive, file, request, version. A port re-exports only what a consumer of that capability uses, and every port carries its own eager-closure row. Most of the old values barrel was never host mechanics. Pure record readers, config-source values, result text, memoization, async scoping, coordinate validation, and device-scope parsing touch no process, file, or environment, so they join kernel's other primitives instead. Closures fall accordingly: capture-kit's png-worker-client from 20 to 10, png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from 386 to 380. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * chore: drop the migration inventories and trim the touched comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: trim the touched host-kit and mutation-lane comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only in the touched files Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only across the touched tree Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: point the Swift parity comment at the real TS twin and test The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts, which does not exist: the module went to capture-kit while isTapPointInsideViewport itself went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at the pre-move path. Both now resolve. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: repoint comment citations at the homes this refactor moved them to The W1 move left ~20 comment citations pointing at src/utils/*.ts and src/request/*.ts paths that no longer exist. Each now names the capability port that owns the symbol, which survives further file moves: exec -> host-kit/command host-process, owner-identity -> host-kit/process diagnostics -> host-kit/diagnostics atomic-file, process-lock -> host-kit/file retry -> host-kit/retry request progress/cancel -> host-kit/request version -> host-kit/version ttl-memo, source-value, parsing, device-isolation, keyed-lock, success-text -> kernel subpaths Comment-only; no closure, budget, or behavior change. ADR citations are left as written, being dated records of the decision rather than live references. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH --------- Co-authored-by: Claude <noreply@anthropic.com>
379 lines
16 KiB
TypeScript
379 lines
16 KiB
TypeScript
// `pnpm check:xctest-selection` — derive, from the Swift sources and the workflow files, which
|
|
// runner XCTest methods each CI lane actually reaches, and fail when a method reaches none
|
|
// (#1781 A7). Also ensures those unit-test methods are stripped from the Apple runner source
|
|
// copied into the npm package.
|
|
//
|
|
// Three lanes run the `AgentDeviceRunnerUITests` bundle, and each reaches a different set:
|
|
//
|
|
// - host macos.yml, macOS host, every PR: the whole bundle as compiled for macOS, minus
|
|
// `-skip-testing:` — the pure runner-decision tests, whose guard is
|
|
// `#if AGENT_DEVICE_RUNNER_UNIT_TESTS` alone.
|
|
// - pr ios.yml, iOS Simulator, every PR: the hand-written `-only-testing:` list.
|
|
// - nightly xctest-nightly.yml, iOS Simulator, scheduled: the whole bundle as compiled for
|
|
// iOS, minus `-skip-testing:` — includes the simulator-only tests, whose guard is
|
|
// `… && os(iOS)` (they launch the host app, route through SpringBoard, or assert an
|
|
// iOS-only branch).
|
|
//
|
|
// The classification therefore lives in the `#if` guards, so this check evaluates them per
|
|
// platform rather than treating a source-level `func test…` as running everywhere. What it
|
|
// holds:
|
|
//
|
|
// 1. Every `-only-testing:`/`-skip-testing:` identifier names a declared method that
|
|
// compiles for that lane's platform. `xcodebuild` treats an identifier matching nothing
|
|
// as an empty set rather than an error, in BOTH directions: an unknown `-only-testing:`
|
|
// drops a test from the PR lane silently, and an unknown `-skip-testing:` re-admits
|
|
// `RunnerTests/testCommand` — not a test but the runner's server entry point, which opens
|
|
// an NWListener and waits 24 hours — into a whole-bundle lane and hangs it.
|
|
// 2. Every declared method is reachable by at least one lane. A test gated to a platform
|
|
// no lane runs (the tvOS-only pair this check found) is dark from the day it is written.
|
|
// 3. The entry point is reachable by no lane at all.
|
|
//
|
|
// The nightly and host lanes also assert their executed count equals the reach derived here
|
|
// (scripts/xctest-run-summary.ts), so a build variant without the unit-test compile flag, or a
|
|
// guard that quietly compiles a file out, reads as red instead of as a smaller green.
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { runCmdSync } from '@agent-device/host-kit/command';
|
|
import type { Platform } from './swift-conditional-compilation.ts';
|
|
import {
|
|
parseDeclaredTestsByPlatform,
|
|
readSwiftSources,
|
|
RUNNER_TESTS_DIR,
|
|
type DeclaredTest,
|
|
type SwiftSource,
|
|
} from './xctest-declarations.ts';
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, '..');
|
|
const packageAppleRunnerScript = path.join(repoRoot, 'scripts/package-apple-runner-source.mjs');
|
|
|
|
/** The macOS host lane, which runs the whole macOS-compiled bundle on every PR. */
|
|
export const HOST_WORKFLOW_FILE = '.github/workflows/macos.yml';
|
|
|
|
/** The PR lane, whose `-only-testing:` list decides what every pull request runs on the simulator. */
|
|
export const PR_WORKFLOW_FILE = '.github/workflows/ios.yml';
|
|
|
|
/** The nightly lane, whose `-skip-testing:` list decides what the full simulator suite leaves out. */
|
|
export const NIGHTLY_WORKFLOW_FILE = '.github/workflows/xctest-nightly.yml';
|
|
|
|
export type LaneId = 'host' | 'pr' | 'nightly';
|
|
|
|
export type Lane = {
|
|
readonly id: LaneId;
|
|
readonly workflow: string;
|
|
readonly platform: Platform;
|
|
/** `whole`: everything compiled minus `-skip-testing:`; `list`: the `-only-testing:` entries. */
|
|
readonly selection: 'whole' | 'list';
|
|
/** The job-summary heading the lane's reporter prints. */
|
|
readonly title: string;
|
|
};
|
|
|
|
export const LANES: readonly Lane[] = [
|
|
{
|
|
id: 'host',
|
|
workflow: HOST_WORKFLOW_FILE,
|
|
platform: 'macOS',
|
|
selection: 'whole',
|
|
title: 'iOS runner host XCTest lane (macOS, no simulator)',
|
|
},
|
|
{
|
|
id: 'pr',
|
|
workflow: PR_WORKFLOW_FILE,
|
|
platform: 'iOS',
|
|
selection: 'list',
|
|
title: 'iOS runner PR XCTest list',
|
|
},
|
|
{
|
|
id: 'nightly',
|
|
workflow: NIGHTLY_WORKFLOW_FILE,
|
|
platform: 'iOS',
|
|
selection: 'whole',
|
|
title: 'iOS runner full XCTest suite',
|
|
},
|
|
];
|
|
|
|
export function lane(id: string): Lane {
|
|
const found = LANES.find((entry) => entry.id === id);
|
|
if (!found) {
|
|
throw new Error(
|
|
`Unknown XCTest lane "${id}"; expected one of ${LANES.map((e) => e.id).join(', ')}.`,
|
|
);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Every workflow whose test identifiers this check owns. All are read, so a workflow that is
|
|
* renamed or deleted fails here rather than leaving a stale claim in the output.
|
|
*/
|
|
export const GUARDED_WORKFLOWS: readonly string[] = LANES.map((entry) => entry.workflow);
|
|
|
|
/**
|
|
* The one method that must be reachable by no lane: `testCommand` is the runner's server
|
|
* entry point (RunnerTests.swift), compiled unconditionally.
|
|
*/
|
|
export const ENTRY_POINT_METHOD = 'RunnerTests/testCommand';
|
|
|
|
// Two guards against reading prose as configuration, both learned the hard way: these
|
|
// workflows discuss their own flags in comments, and this check's first draft counted the
|
|
// comments. A line whose first non-space character is `#` is a comment in YAML and in the
|
|
// `run:` shell alike, so it can never be a flag xcodebuild sees; and the identifier must
|
|
// have the `Target/Class/method` shape, so a prose mention with no identifier after the
|
|
// colon matches nothing. A typo'd identifier is still identifier-shaped, so both guards
|
|
// narrow what counts as a flag without narrowing what counts as a defect.
|
|
const YAML_COMMENT = /^\s*#/;
|
|
// Global: a `run:` line may carry more than one flag (nothing stops `-only-testing:A -only-testing:B`
|
|
// on one line), and matching only the first would make the second invisible — silently permissive in
|
|
// the skip direction, where an unseen entry is a lane that stops skipping the 24-hour entry point.
|
|
const TEST_FLAG = /-(only|skip)-testing:([A-Za-z_][\w.+-]*(?:\/[A-Za-z_]\w*){1,2})/g;
|
|
|
|
export type TestFlag = 'only-testing' | 'skip-testing';
|
|
|
|
/** One `-only-testing:`/`-skip-testing:` identifier as written, and where it was written. */
|
|
export type FlaggedTest = {
|
|
readonly workflow: string;
|
|
readonly flag: TestFlag;
|
|
readonly identifier: string;
|
|
readonly line: number;
|
|
};
|
|
|
|
export type SelectionReport = {
|
|
readonly target: string;
|
|
/** Every `Target/Class/method` the Swift sources declare, sorted, regardless of guards. */
|
|
readonly declared: readonly string[];
|
|
/** The same set with the platforms each method compiles for. */
|
|
readonly declaredTests: readonly DeclaredTest[];
|
|
/** Every flagged identifier across the guarded workflows, in file order. */
|
|
readonly flagged: readonly FlaggedTest[];
|
|
/** Guarded workflows that do not exist — a claim this check can no longer make. */
|
|
readonly missingWorkflows: readonly string[];
|
|
/** Flagged identifiers naming no declared method — a failure. */
|
|
readonly unknown: readonly FlaggedTest[];
|
|
/** Flagged identifiers naming a method its lane's platform never compiles — a failure. */
|
|
readonly uncompiled: readonly FlaggedTest[];
|
|
/** What each lane reaches, once guards and flags are resolved. */
|
|
readonly reach: Readonly<Record<LaneId, ReadonlySet<string>>>;
|
|
/** Declared methods no lane reaches, entry point excluded — a failure. */
|
|
readonly dark: readonly string[];
|
|
/** Lanes that reach the entry point — a failure (a 24-hour hang). */
|
|
readonly entryPointReachedBy: readonly LaneId[];
|
|
};
|
|
|
|
export type WorkflowSource = { readonly workflow: string; readonly text: string | null };
|
|
|
|
/** Every `-only-testing:`/`-skip-testing:` identifier a workflow names, with its line. */
|
|
export function parseFlaggedTests(workflow: string, text: string): FlaggedTest[] {
|
|
return text.split('\n').flatMap((line, index) => {
|
|
if (YAML_COMMENT.test(line)) return [];
|
|
return [...line.matchAll(TEST_FLAG)].map((match) => ({
|
|
workflow,
|
|
flag: `${match[1]}-testing` as TestFlag,
|
|
identifier: match[2] as string,
|
|
line: index + 1,
|
|
}));
|
|
});
|
|
}
|
|
|
|
/** Attributed per workflow, not just per flag: each lane's number has to be its own. */
|
|
function identifiers(
|
|
flagged: readonly FlaggedTest[],
|
|
workflow: string,
|
|
flag: TestFlag,
|
|
): Set<string> {
|
|
return new Set(
|
|
flagged
|
|
.filter((entry) => entry.workflow === workflow && entry.flag === flag)
|
|
.map((entry) => entry.identifier),
|
|
);
|
|
}
|
|
|
|
/** What one lane reaches: its platform's compiled set, narrowed by its flags. */
|
|
function laneReach(
|
|
entry: Lane,
|
|
declaredTests: readonly DeclaredTest[],
|
|
flagged: readonly FlaggedTest[],
|
|
): Set<string> {
|
|
const only = identifiers(flagged, entry.workflow, 'only-testing');
|
|
const skipped = identifiers(flagged, entry.workflow, 'skip-testing');
|
|
return new Set(
|
|
declaredTests
|
|
.filter((test) => test.platforms.includes(entry.platform))
|
|
.map((test) => test.identifier)
|
|
.filter((id) => (entry.selection === 'whole' || only.has(id)) && !skipped.has(id)),
|
|
);
|
|
}
|
|
|
|
export function buildReport(
|
|
target: string,
|
|
sources: readonly SwiftSource[],
|
|
workflows: readonly WorkflowSource[],
|
|
): SelectionReport {
|
|
const declaredTests = parseDeclaredTestsByPlatform(target, sources);
|
|
const declared = declaredTests.map((test) => test.identifier);
|
|
const known = new Map(declaredTests.map((test) => [test.identifier, test]));
|
|
const flagged = workflows.flatMap((entry) =>
|
|
entry.text === null ? [] : parseFlaggedTests(entry.workflow, entry.text),
|
|
);
|
|
const platformOf = new Map(LANES.map((entry) => [entry.workflow, entry.platform]));
|
|
// Identifiers for another target are left alone: this check owns one target's sources
|
|
// and cannot speak for anything else a workflow might select.
|
|
const owned = flagged.filter((entry) => entry.identifier.startsWith(`${target}/`));
|
|
const reach = Object.fromEntries(
|
|
LANES.map((entry) => [entry.id, laneReach(entry, declaredTests, flagged)]),
|
|
) as Record<LaneId, ReadonlySet<string>>;
|
|
const entryPoint = `${target}/${ENTRY_POINT_METHOD}`;
|
|
const reachedAnywhere = new Set(LANES.flatMap((entry) => [...reach[entry.id]]));
|
|
return {
|
|
target,
|
|
declared,
|
|
declaredTests,
|
|
flagged,
|
|
missingWorkflows: workflows.filter((entry) => entry.text === null).map((e) => e.workflow),
|
|
unknown: owned.filter((entry) => !known.has(entry.identifier)),
|
|
uncompiled: owned.filter((entry) => {
|
|
const test = known.get(entry.identifier);
|
|
const platform = platformOf.get(entry.workflow);
|
|
return test !== undefined && platform !== undefined && !test.platforms.includes(platform);
|
|
}),
|
|
reach,
|
|
dark: declared.filter((id) => id !== entryPoint && !reachedAnywhere.has(id)),
|
|
entryPointReachedBy: LANES.filter((entry) => reach[entry.id].has(entryPoint)).map(
|
|
(entry) => entry.id,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function loadReport(root: string = repoRoot): SelectionReport {
|
|
const directory = path.join(root, RUNNER_TESTS_DIR);
|
|
return buildReport(
|
|
path.basename(directory),
|
|
readSwiftSources(directory),
|
|
GUARDED_WORKFLOWS.map((workflow) => {
|
|
const file = path.join(root, workflow);
|
|
return { workflow, text: fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null };
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** The partition a reader needs: how many methods each lane reaches, and how many none does. */
|
|
export function counts(report: SelectionReport): {
|
|
declared: number;
|
|
host: number;
|
|
pr: number;
|
|
nightly: number;
|
|
dark: number;
|
|
} {
|
|
return {
|
|
declared: report.declared.length,
|
|
host: report.reach.host.size,
|
|
pr: report.reach.pr.size,
|
|
nightly: report.reach.nightly.size,
|
|
dark: report.dark.length,
|
|
};
|
|
}
|
|
|
|
/** The failures, or an empty list. Kept apart from formatting so the test can assert both. */
|
|
export function reportFailures(report: SelectionReport): string[] {
|
|
// A parser that stops matching would report "0 unknown" forever, which reads exactly
|
|
// like a healthy list. Both inputs are non-empty by construction, so an empty parse is
|
|
// the check going blind, not a real state of the tree.
|
|
if (report.missingWorkflows.length > 0) {
|
|
return [
|
|
`Missing guarded workflow(s): ${report.missingWorkflows.join(', ')}. This check names ` +
|
|
'them in its own output, so a renamed or deleted lane must be reflected in ' +
|
|
'LANES rather than leaving a claim nothing backs.',
|
|
];
|
|
}
|
|
if (report.declared.length === 0) {
|
|
return [
|
|
`Found no test methods in ${RUNNER_TESTS_DIR}. The declaration scan is broken, ` +
|
|
'so this check can no longer see a dropped test.',
|
|
];
|
|
}
|
|
if (report.flagged.length === 0) {
|
|
return [
|
|
'Found no `-only-testing:`/`-skip-testing:` entries in ' +
|
|
`${GUARDED_WORKFLOWS.join(', ')}. Either every lane stopped filtering (drop this ` +
|
|
'check), or the scan is broken and can no longer see a dropped test.',
|
|
];
|
|
}
|
|
const failures: string[] = [];
|
|
const flagLine = (entry: FlaggedTest) =>
|
|
` - ${entry.workflow}:${entry.line} (-${entry.flag}) ${entry.identifier}`;
|
|
if (report.unknown.length > 0) {
|
|
failures.push(
|
|
`${report.unknown.length} XCTest identifier(s) name a method no source declares:`,
|
|
...report.unknown.map(flagLine),
|
|
'xcodebuild matches nothing and still exits 0 for an unknown identifier, in both',
|
|
'directions: an unknown `-only-testing:` drops a test from the PR lane silently, and an',
|
|
'unknown `-skip-testing:` re-admits whatever a whole-bundle lane meant to leave out —',
|
|
`including ${report.target}/${ENTRY_POINT_METHOD}, the runner's 24-hour server entry point.`,
|
|
'Update the entry to the current name, or remove it.',
|
|
);
|
|
}
|
|
if (report.uncompiled.length > 0) {
|
|
failures.push(
|
|
`${report.uncompiled.length} XCTest identifier(s) name a method that lane's platform ` +
|
|
'never compiles:',
|
|
...report.uncompiled.map(flagLine),
|
|
"The method exists, but its `#if` guard compiles it out of that lane's build, so the",
|
|
'flag matches nothing there. Move the entry to a lane whose platform compiles it, or',
|
|
'widen the guard.',
|
|
);
|
|
}
|
|
if (report.dark.length > 0) {
|
|
failures.push(
|
|
`${report.dark.length} declared XCTest method(s) are reachable by no lane:`,
|
|
...report.dark.map((identifier) => ` - ${identifier}`),
|
|
'The host lane runs everything the macOS build compiles, the nightly everything the iOS',
|
|
'build compiles, and the PR list names its methods; a method outside all three — usually',
|
|
'a guard naming a platform no lane runs — is dark from the day it is written. Widen the',
|
|
'guard, list it, or delete it.',
|
|
);
|
|
}
|
|
if (report.entryPointReachedBy.length > 0) {
|
|
failures.push(
|
|
`${report.target}/${ENTRY_POINT_METHOD} is reachable by lane(s): ` +
|
|
`${report.entryPointReachedBy.join(', ')}.`,
|
|
'It is not a test: it opens an NWListener and waits 24 hours for a client, so a lane that',
|
|
'runs it hangs until timeout-minutes. Whole-bundle lanes must keep their -skip-testing:',
|
|
'entry for it; the PR list must not name it.',
|
|
);
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
export function formatSummary(report: SelectionReport): string {
|
|
const { declared, host, pr, nightly, dark } = counts(report);
|
|
return (
|
|
`xctest selection: ${declared} declared ${report.target} methods — host lane ` +
|
|
`(${HOST_WORKFLOW_FILE}, macOS, every PR) reaches ${host}, PR list (${PR_WORKFLOW_FILE}, ` +
|
|
`iOS Simulator, every PR) selects ${pr}, nightly (${NIGHTLY_WORKFLOW_FILE}, iOS Simulator) ` +
|
|
`reaches ${nightly}; ${dark} reachable by no lane; ${ENTRY_POINT_METHOD} skipped everywhere.\n`
|
|
);
|
|
}
|
|
|
|
/** Reuse the package builder's source guard without writing dist or packing a tarball. */
|
|
export function runnerPackageSourceFailures(root: string = repoRoot): string[] {
|
|
const result = runCmdSync(
|
|
process.execPath,
|
|
[packageAppleRunnerScript, '--root', root, '--check', '--quiet'],
|
|
{ allowFailure: true },
|
|
);
|
|
if (result.exitCode === 0) return [];
|
|
const detail = (result.stderr || result.stdout).trim();
|
|
return [`Apple runner package source guard failed:\n${detail || 'unknown failure'}`];
|
|
}
|
|
|
|
function main(): number {
|
|
const report = loadReport();
|
|
const failures = [...reportFailures(report), ...runnerPackageSourceFailures()];
|
|
process.stdout.write(formatSummary(report));
|
|
if (failures.length === 0) return 0;
|
|
process.stderr.write(`${failures.join('\n')}\n`);
|
|
return 1;
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) process.exit(main());
|