mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
df0a0f7fd2
* perf(package): strip comments from the Apple runner source the npm package ships The packager copies apple/runner/** into dist/ as Swift source, removing only its AGENT_DEVICE_RUNNER_UNIT_TESTS blocks, so doc comments and design notes were downloaded on every install: 71.9 kB of 441.2 kB of packaged runner Swift. Add a lexical scanner for the removal. A regex cannot do this: `//` and `/*` open a comment only in code position, raw literals move their own delimiter and escape with the `#` count, interpolation segments hold code and further literals, and Swift block comments nest. A construct the scanner cannot account for throws at packaging time instead of shipping Swift that does not compile. * fix(package): keep Swift regex literals out of the comment scanner `#/foo//bar/#` is a valid extended regex literal with no comment in it, but the scanner only knew the `#"` raw-string family, so it read the literal's `//` as a line comment and shipped `let pattern = #/foo` — Swift that does not compile. Add `#/…/#` and `##/…/##` as a literal context: matching `#` counts, the single- and multi-line forms, Swift's own-line rule for a multi-line closing delimiter, and the `\/` escape that keeps one from closing early. Bare `/…/` literals stay unresolvable, because the same `/` opens a comment, divides, and starts a regex literal, and only the parse separates them. Where one could begin — an expression position whose `/` is not followed by a space, a tab or `)` — packaging throws by file and line instead of rewriting bytes it cannot prove are code. Divisions (`width/2`, `Double(3)/Double(4)`), the recording scripts' shebang and `(/)` keep flowing through. * fix(package): keep the packaged runner source on the checkout's line numbers `dist/apple/runner/**` is the Swift a user's `xcodebuild` and the runner name a file and line in (it lands in runner.log), so those numbers are only worth reading if they point at the same line of `apple/runner/**`. Both rewriting passes now empty the lines they remove instead of deleting them: comment removal (889 lines, 889 B) and the pre-existing unit-test `#if` block strip, which was moving everything below a block by up to 883 lines (3,737 lines, 3,737 B). `dist/apple/runner/` 555,907 B -> 488,635 B (-67,272 B, -12.1%); its Swift alone 441,196 B -> 373,924 B (-15.2%). Parity costs 4,626 B of the 71,898 B the previous head saved. Nothing in the repo compiles the packaged source, so a mis-lex that failed to throw would ship Swift that does not build and no gate would see it. Add `pnpm check:packaged-runner-swift`: it packages into a throwaway root and asserts line-count parity plus the line of every declaration each packaged file still carries, then runs `swiftc -parse` over all 44 files. The parse half reports itself skipped where no Swift toolchain exists, so the gate is declared on the macOS lane, where both halves run.
355 lines
12 KiB
JavaScript
355 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { stripSwiftComments } from './strip-swift-comments.mjs';
|
|
|
|
const UNIT_TEST_CONDITION = 'AGENT_DEVICE_RUNNER_UNIT_TESTS';
|
|
const SOURCE_DIR = path.join('apple', 'runner');
|
|
const OUTPUT_DIR = path.join('dist', 'apple', 'runner');
|
|
const SNAPSHOT_PRESENTATION_SOURCE_DIR = path.join('apple', 'snapshot-presentation');
|
|
const SNAPSHOT_PRESENTATION_OUTPUT_DIR = path.join('dist', 'apple', 'snapshot-presentation');
|
|
const SNAPSHOT_PRESENTATION_RUNNER_MANIFEST = 'Package.runner.swift';
|
|
const SNAPSHOT_PRESENTATION_DEVELOPMENT_DIR_NAMES = new Set([
|
|
'Tests',
|
|
'SnapshotPresentationConformance',
|
|
'.build',
|
|
'.swiftpm',
|
|
'UnitTests',
|
|
'xcuserdata',
|
|
]);
|
|
// Packaged-runner locations from before the apple-runner/ -> apple/runner/ move. `dist` ships
|
|
// wholesale, so a stale tree left by an older build/checkout would double-ship into the npm
|
|
// package (and inflate the bundle-size diff, which packages the base then the PR into one dist).
|
|
// Always remove them so only the current OUTPUT_DIR survives.
|
|
const LEGACY_OUTPUT_DIRS = [
|
|
path.join('dist', 'apple-runner'),
|
|
path.join('dist', 'apple', 'apple-runner'),
|
|
];
|
|
const SKIPPED_DIR_NAMES = new Set(['.build', '.swiftpm', 'UnitTests', 'xcuserdata']);
|
|
const SKIPPED_ROOT_FILES = new Set(['README.md', 'RUNNER_PROTOCOL.md']);
|
|
// XCTest discovers instance methods named test*; anything matching this that survives stripping
|
|
// would ship to (and compile on) every user's machine. Only the runner's command-loop entrypoint
|
|
// is a legitimate test method in the packaged source.
|
|
const SHIPPED_TEST_METHOD_ALLOWLIST = new Map([
|
|
[
|
|
path.join('AgentDeviceRunner', 'AgentDeviceRunnerUITests', 'RunnerTests.swift'),
|
|
new Set(['testCommand']),
|
|
],
|
|
]);
|
|
const TEST_METHOD_PATTERN = /^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:\w+\s+)*func\s+(test\w*)\s*\(/;
|
|
|
|
function packageAppleRunnerSource(options = {}) {
|
|
const root = path.resolve(options.root ?? process.cwd());
|
|
const sourceRoot = path.join(root, SOURCE_DIR);
|
|
const outputRoot = path.join(root, OUTPUT_DIR);
|
|
if (!fs.existsSync(sourceRoot)) {
|
|
throw new Error(`Apple runner source not found at ${sourceRoot}`);
|
|
}
|
|
|
|
prepareOutput(root, outputRoot, options.checkOnly);
|
|
const summary = {
|
|
outputRoot,
|
|
copiedFiles: 0,
|
|
strippedFiles: 0,
|
|
strippedBlocks: 0,
|
|
strippedComments: 0,
|
|
strippedCommentBytes: 0,
|
|
};
|
|
|
|
processDirectory(sourceRoot, options.checkOnly ? undefined : outputRoot, '', summary);
|
|
packageSnapshotPresentationSource(root, options, summary);
|
|
return summary;
|
|
}
|
|
|
|
function packageSnapshotPresentationSource(root, options, summary) {
|
|
const sourceRoot = path.join(root, SNAPSHOT_PRESENTATION_SOURCE_DIR);
|
|
if (!fs.existsSync(sourceRoot)) {
|
|
throw new Error(`Apple snapshot presentation source not found at ${sourceRoot}`);
|
|
}
|
|
const outputRoot = path.join(root, SNAPSHOT_PRESENTATION_OUTPUT_DIR);
|
|
const manifestSource = requireSnapshotPresentationManifest(sourceRoot);
|
|
processDirectory(sourceRoot, options.checkOnly ? undefined : outputRoot, '', summary, {
|
|
validateSwift: false,
|
|
skipDirectoryNames: SNAPSHOT_PRESENTATION_DEVELOPMENT_DIR_NAMES,
|
|
skipFilePaths: new Set(['Package.swift', SNAPSHOT_PRESENTATION_RUNNER_MANIFEST]),
|
|
});
|
|
copySnapshotPresentationManifest(manifestSource, outputRoot, summary, options.checkOnly);
|
|
}
|
|
|
|
function requireSnapshotPresentationManifest(sourceRoot) {
|
|
const manifestSource = path.join(sourceRoot, SNAPSHOT_PRESENTATION_RUNNER_MANIFEST);
|
|
if (fs.existsSync(manifestSource)) return manifestSource;
|
|
throw new Error(`Apple snapshot presentation runner manifest not found at ${manifestSource}`);
|
|
}
|
|
|
|
function copySnapshotPresentationManifest(manifestSource, outputRoot, summary, checkOnly) {
|
|
if (checkOnly) return;
|
|
fs.copyFileSync(manifestSource, path.join(outputRoot, 'Package.swift'));
|
|
summary.copiedFiles += 1;
|
|
}
|
|
|
|
function prepareOutput(root, outputRoot, checkOnly) {
|
|
if (checkOnly) {
|
|
return;
|
|
}
|
|
fs.rmSync(outputRoot, { recursive: true, force: true });
|
|
fs.rmSync(path.join(root, SNAPSHOT_PRESENTATION_OUTPUT_DIR), { recursive: true, force: true });
|
|
for (const legacyDir of LEGACY_OUTPUT_DIRS) {
|
|
fs.rmSync(path.join(root, legacyDir), { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function stripRunnerUnitTestBlocks(source, filePath = '<swift source>') {
|
|
const lines = source.match(/[^\n]*\n|[^\n]+/g) ?? [];
|
|
const state = {
|
|
output: [],
|
|
strippedBlocks: 0,
|
|
skippedDepth: 0,
|
|
};
|
|
|
|
for (const line of lines) {
|
|
consumeSwiftLine(state, line);
|
|
}
|
|
|
|
if (state.skippedDepth !== 0) {
|
|
throw new Error(`Unterminated ${UNIT_TEST_CONDITION} block in ${filePath}`);
|
|
}
|
|
|
|
return {
|
|
contents: state.output.join(''),
|
|
strippedBlocks: state.strippedBlocks,
|
|
};
|
|
}
|
|
|
|
function consumeSwiftLine(state, line) {
|
|
if (state.skippedDepth > 0) {
|
|
consumeSkippedConditionalLine(state, line);
|
|
return;
|
|
}
|
|
if (isRunnerUnitTestBlockStart(line)) {
|
|
state.skippedDepth = 1;
|
|
state.strippedBlocks += 1;
|
|
state.output.push(emptiedLine(line));
|
|
return;
|
|
}
|
|
state.output.push(line);
|
|
}
|
|
|
|
function consumeSkippedConditionalLine(state, line) {
|
|
if (isConditionalStart(line)) {
|
|
state.skippedDepth += 1;
|
|
}
|
|
if (isConditionalEnd(line)) {
|
|
state.skippedDepth -= 1;
|
|
}
|
|
state.output.push(emptiedLine(line));
|
|
}
|
|
|
|
/**
|
|
* A removed line, reduced to its newline. Keeping it is what makes the packaged file's line N the
|
|
* same line N as the checkout's: a user's `xcodebuild` failure names the packaged path, and the
|
|
* block strip would otherwise move everything below a unit-test block by hundreds of lines.
|
|
*/
|
|
function emptiedLine(line) {
|
|
return line.endsWith('\n') ? '\n' : '';
|
|
}
|
|
|
|
function processDirectory(sourceDir, outputDir, relativeDir, summary, options = {}) {
|
|
if (outputDir) {
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
}
|
|
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
processDirectoryEntry(entry, sourceDir, outputDir, relativeDir, summary, options);
|
|
}
|
|
}
|
|
|
|
function processDirectoryEntry(entry, sourceDir, outputDir, relativeDir, summary, options) {
|
|
const relativePath = path.join(relativeDir, entry.name);
|
|
if (shouldSkipEntry(entry, relativePath, options)) {
|
|
return;
|
|
}
|
|
|
|
processIncludedEntry(entry, sourceDir, outputDir, relativePath, summary, options);
|
|
}
|
|
|
|
function processIncludedEntry(entry, sourceDir, outputDir, relativePath, summary, options) {
|
|
const sourcePath = path.join(sourceDir, entry.name);
|
|
const outputPath = outputDir ? path.join(outputDir, entry.name) : undefined;
|
|
if (entry.isDirectory()) {
|
|
processDirectory(sourcePath, outputPath, relativePath, summary, options);
|
|
return;
|
|
}
|
|
if (!entry.isFile()) {
|
|
return;
|
|
}
|
|
processFile(sourcePath, outputPath, relativePath, summary, options);
|
|
}
|
|
|
|
function processFile(sourcePath, outputPath, relativePath, summary, options) {
|
|
if (outputPath) {
|
|
copyFile(sourcePath, outputPath, relativePath, summary, options);
|
|
return;
|
|
}
|
|
validateFile(sourcePath, relativePath, summary, options);
|
|
}
|
|
|
|
function copyFile(sourcePath, outputPath, relativePath, summary, options) {
|
|
if (path.extname(sourcePath) !== '.swift') {
|
|
fs.copyFileSync(sourcePath, outputPath);
|
|
summary.copiedFiles += 1;
|
|
return;
|
|
}
|
|
|
|
if (options.validateSwift === false) {
|
|
fs.copyFileSync(sourcePath, outputPath);
|
|
summary.copiedFiles += 1;
|
|
return;
|
|
}
|
|
|
|
const stripped = validateSwiftFile(sourcePath, relativePath, summary);
|
|
fs.writeFileSync(outputPath, stripped.contents);
|
|
summary.copiedFiles += 1;
|
|
}
|
|
|
|
function validateFile(sourcePath, relativePath, summary, options) {
|
|
if (path.extname(sourcePath) !== '.swift') {
|
|
return undefined;
|
|
}
|
|
if (options.validateSwift === false) {
|
|
return undefined;
|
|
}
|
|
return validateSwiftFile(sourcePath, relativePath, summary);
|
|
}
|
|
|
|
// The unit-test strip runs first and stays line-based, so which blocks it removes does not depend
|
|
// on comment removal. The comment scanner then reads Swift that is already in its shipped shape,
|
|
// and the shipped-test-method guard sees exactly the text the package will contain. Both passes
|
|
// empty the lines they remove rather than deleting them, so the packaged file has the checkout's
|
|
// line numbering; `pnpm check:packaged-runner-swift` asserts that, file by file.
|
|
function validateSwiftFile(sourcePath, relativePath, summary) {
|
|
const source = fs.readFileSync(sourcePath, 'utf8');
|
|
const stripped = stripRunnerUnitTestBlocks(source, sourcePath);
|
|
const withoutComments = stripSwiftComments(stripped.contents, sourcePath);
|
|
assertNoShippedTestMethods(withoutComments.contents, relativePath);
|
|
if (stripped.strippedBlocks > 0) {
|
|
summary.strippedFiles += 1;
|
|
summary.strippedBlocks += stripped.strippedBlocks;
|
|
}
|
|
summary.strippedComments += withoutComments.removedComments;
|
|
summary.strippedCommentBytes +=
|
|
Buffer.byteLength(stripped.contents) - Buffer.byteLength(withoutComments.contents);
|
|
return { contents: withoutComments.contents, strippedBlocks: stripped.strippedBlocks };
|
|
}
|
|
|
|
function assertNoShippedTestMethods(strippedContents, relativePath) {
|
|
const allowedMethods = SHIPPED_TEST_METHOD_ALLOWLIST.get(relativePath) ?? new Set();
|
|
const shippedMethods = strippedContents
|
|
.split('\n')
|
|
.map((line) => TEST_METHOD_PATTERN.exec(line)?.[1])
|
|
.filter((method) => method !== undefined && !allowedMethods.has(method));
|
|
if (shippedMethods.length > 0) {
|
|
throw new Error(
|
|
`Unit test ${shippedMethods[0]}() in ${relativePath} would ship in the npm package; ` +
|
|
`wrap it in #if ${UNIT_TEST_CONDITION}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function shouldSkipEntry(entry, relativePath, options) {
|
|
return shouldSkipDirectory(entry, options) || shouldSkipFile(entry, relativePath, options);
|
|
}
|
|
|
|
function shouldSkipDirectory(entry, options) {
|
|
return entry.isDirectory() && (options.skipDirectoryNames ?? SKIPPED_DIR_NAMES).has(entry.name);
|
|
}
|
|
|
|
function shouldSkipFile(entry, relativePath, options) {
|
|
return (
|
|
entry.isFile() &&
|
|
(isXcodeUserStateFile(entry) ||
|
|
isSkippedRootFile(entry, relativePath) ||
|
|
options.skipFilePaths?.has(relativePath) === true)
|
|
);
|
|
}
|
|
|
|
function isXcodeUserStateFile(entry) {
|
|
return entry.name.endsWith('.xcuserstate');
|
|
}
|
|
|
|
function isSkippedRootFile(entry, relativePath) {
|
|
return !relativePath.includes(path.sep) && SKIPPED_ROOT_FILES.has(entry.name);
|
|
}
|
|
|
|
function isRunnerUnitTestBlockStart(line) {
|
|
return new RegExp(`^\\s*#if\\s+${UNIT_TEST_CONDITION}(?:\\b|$)`).test(line);
|
|
}
|
|
|
|
function isConditionalStart(line) {
|
|
return /^\s*#if\b/.test(line);
|
|
}
|
|
|
|
function isConditionalEnd(line) {
|
|
return /^\s*#endif\b/.test(line);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const parsed = { root: process.cwd(), quiet: false, checkOnly: false };
|
|
let index = 0;
|
|
while (index < argv.length) {
|
|
index = parseArg(argv, index, parsed);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parseArg(argv, index, parsed) {
|
|
const arg = argv[index];
|
|
if (arg === '--quiet') {
|
|
parsed.quiet = true;
|
|
return index + 1;
|
|
}
|
|
if (arg === '--check') {
|
|
parsed.checkOnly = true;
|
|
return index + 1;
|
|
}
|
|
if (arg === '--root') {
|
|
return parseRootArg(argv, index, parsed);
|
|
}
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
|
|
function parseRootArg(argv, index, parsed) {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith('--')) {
|
|
throw new Error('--root requires a path');
|
|
}
|
|
parsed.root = value;
|
|
return index + 2;
|
|
}
|
|
|
|
function isMainModule() {
|
|
return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
}
|
|
|
|
if (isMainModule()) {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const summary = packageAppleRunnerSource(options);
|
|
if (!options.quiet) {
|
|
if (options.checkOnly) {
|
|
console.log(
|
|
`Apple runner source package guard passed ` +
|
|
`(${summary.strippedFiles} files contain ${summary.strippedBlocks} stripped blocks).`,
|
|
);
|
|
} else {
|
|
const relativeOutput = path.relative(path.resolve(options.root), summary.outputRoot);
|
|
console.log(
|
|
`Packaged Apple runner source at ${relativeOutput} ` +
|
|
`(${summary.copiedFiles} files, stripped ${summary.strippedBlocks} unit-test blocks ` +
|
|
`and ${summary.strippedComments} comments worth ${summary.strippedCommentBytes} bytes).`,
|
|
);
|
|
}
|
|
}
|
|
}
|