gstack/test/skill-e2e-ios-swift-build.t...

186 lines
8.2 KiB
TypeScript

// Swift-build invariant tests. Runs against the fixture iOS app at
// test/fixtures/ios-qa/FixtureApp/. Requires the Swift toolchain
// (Xcode CLI tools or stand-alone Swift). Skipped if swift is not on PATH.
//
// Two invariants:
//
// 1. Debug-config build succeeds + the StateServer XCTest unit suite
// passes (validates that the Swift production code actually runs,
// not just compiles).
//
// 2. Release-config build excludes DebugBridge symbols. This is the
// structural Release-build guard from Package.swift's
// `.when(configuration: .debug)`. We verify by:
// a. swift build -c release succeeds
// b. nm -j against the built binary shows zero `DebugBridge*`
// symbols
// c. swift build -c release with --vv shows DebugBridge target
// gated (no compilation step for DebugBridgeCore/UI)
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
const ROOT = join(import.meta.dir, '..');
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates');
describe('template ↔ fixture parity', () => {
test('Package.swift.template declares all 3 DebugBridge targets', () => {
const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
// Each target must be present as a library product AND a target definition.
for (const name of ['DebugBridgeCore', 'DebugBridgeUI', 'DebugBridgeTouch']) {
expect(tmpl).toContain(`name: "${name}"`);
}
// DebugBridgeUI must depend on the other two; that's how the consuming
// app gets the transitive set with one dependency entry.
expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/);
});
test('KIF-derived touch source preserves Apache-2.0 attribution', () => {
const implementation = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8');
expect(implementation).toContain('Copyright 2011-2016 Square, Inc. and KIF contributors.');
expect(implementation).toContain('Apache License, Version 2.0');
expect(implementation).not.toContain('MIT-licensed');
});
});
function hasSwift(): boolean {
const r = spawnSync('swift', ['--version'], { stdio: 'pipe' });
return r.status === 0;
}
const swiftAvailable = hasSwift();
const describeIfSwift = swiftAvailable ? describe : describe.skip;
describeIfSwift('swift build invariants', () => {
// DebugBridgeUI + DebugBridgeTouch are iOS-only (they link UIKit). Plain
// `swift build` on macOS host can't resolve UIKit, so we scope these
// invariants to DebugBridgeCore (Swift, cross-platform) + its XCTest
// target. The iOS-only targets are covered by xcodebuild on the device
// path (test/skill-e2e-ios-device.test.ts).
test('Debug-config build succeeds (DebugBridgeCore)', () => {
const r = spawnSync('swift', ['build', '-c', 'debug', '--target', 'DebugBridgeCore'], {
cwd: FIXTURE_PATH,
stdio: 'pipe',
timeout: 120_000,
});
if (r.status !== 0) {
console.error('swift build stderr:', r.stderr?.toString().slice(0, 4000));
}
expect(r.status).toBe(0);
}, 180_000);
test('XCTest suite for StateServer passes (validates real Swift impl)', () => {
const r = spawnSync('swift', ['test', '--filter', 'DebugBridgeCoreTests'], {
cwd: FIXTURE_PATH,
stdio: 'pipe',
timeout: 180_000,
});
const stdout = r.stdout?.toString() ?? '';
const stderr = r.stderr?.toString() ?? '';
const combined = stdout + stderr;
if (r.status !== 0) {
console.error('swift test failure:', combined.slice(-4000));
}
expect(r.status).toBe(0);
// --filter scopes the run to DebugBridgeCoreTests; the xctest summary
// line is "'Selected tests' passed" rather than "'All tests' passed".
expect(combined).toMatch(/'(?:All|Selected) tests' passed/);
// Guard against an empty pass-by-no-tests (filter typo / target rename):
// we expect at least one StateServer smoke test to actually execute.
expect(combined).toContain('StateServerSmokeTests');
}, 240_000);
// Codex-flagged: Release-build guard must be STRUCTURAL, not advisory.
// The Package.swift's `.when(configuration: .debug)` setting causes Swift
// to compile-out the entire DebugBridgeCore target body in Release. Since
// every public symbol is gated `#if DEBUG`, the release build emits an
// empty module — zero symbols.
test('Release-config build excludes DebugBridge symbols', () => {
// Step 1: clean + release build (Core only — UI/Touch can't build on macOS)
spawnSync('swift', ['package', 'clean'], { cwd: FIXTURE_PATH, stdio: 'pipe', timeout: 60_000 });
const build = spawnSync('swift', ['build', '-c', 'release', '--target', 'DebugBridgeCore'], {
cwd: FIXTURE_PATH,
stdio: 'pipe',
timeout: 180_000,
});
if (build.status !== 0) {
console.error('release build stderr:', build.stderr?.toString().slice(0, 4000));
}
expect(build.status).toBe(0);
// Step 2: locate the built object file(s). SwiftPM puts .build artifacts
// under .build/<triple>/release/.
const oFiles = spawnSync('find', [
join(FIXTURE_PATH, '.build'),
'-path', '*/release/*',
'-name', '*.o',
'-path', '*DebugBridge*',
], { stdio: 'pipe' });
const files = (oFiles.stdout?.toString() ?? '').trim().split('\n').filter(Boolean);
expect(files.length).toBeGreaterThan(0);
let foundForbidden = 0;
const forbidden = ['StateServer', 'handleRequest', 'sessionAcquire', 'authRotate', 'snapshotGet'];
for (const f of files) {
const nm = spawnSync('nm', ['-j', f], { stdio: 'pipe' });
const syms = nm.stdout?.toString() ?? '';
for (const tok of forbidden) {
if (syms.includes(tok)) {
console.error(`Release symbol leak: ${tok} found in ${f}`);
foundForbidden++;
}
}
}
expect(foundForbidden).toBe(0);
}, 300_000);
test('final Release iOS app excludes private touch APIs', () => {
if (spawnSync('xcrun', ['--find', 'xcodebuild']).status !== 0 ||
spawnSync('xcodegen', ['--version']).status !== 0) return;
const workDir = mkdtempSync(join(tmpdir(), 'gstack-ios-release-guard-'));
const fixtureDir = join(workDir, 'FixtureApp');
try {
cpSync(FIXTURE_PATH, fixtureDir, { recursive: true });
writeFileSync(
join(fixtureDir, 'Package.swift'),
readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'),
);
writeFileSync(
join(fixtureDir, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'),
readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'),
);
writeFileSync(
join(fixtureDir, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'),
readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'),
);
expect(spawnSync('xcodegen', [
'generate', '--spec', join(fixtureDir, 'project.yml'),
'--project', fixtureDir, '--project-root', fixtureDir,
], { cwd: fixtureDir, stdio: 'pipe' }).status).toBe(0);
const derivedData = join(workDir, 'DerivedData');
const build = spawnSync('xcodebuild', [
'-project', join(fixtureDir, 'FixtureApp.xcodeproj'),
'-scheme', 'FixtureApp', '-configuration', 'Release',
'-sdk', 'iphonesimulator', '-destination', 'generic/platform=iOS Simulator',
'-derivedDataPath', derivedData, 'CODE_SIGNING_ALLOWED=NO', 'build',
], { cwd: fixtureDir, stdio: 'pipe', timeout: 300_000 });
expect(build.status).toBe(0);
const binary = join(derivedData, 'Build/Products/Release-iphonesimulator/FixtureApp.app/FixtureApp');
const contents = spawnSync('strings', ['-a', binary]).stdout?.toString() ?? '';
for (const forbidden of [
'DebugBridgeTouch', '_touchesEvent', '_AXSSetAutomationEnabled',
'IOHIDEventCreateDigitizerEvent',
]) expect(contents).not.toContain(forbidden);
} finally {
rmSync(workDir, { recursive: true, force: true });
}
}, 360_000);
});