diff --git a/native/cu-helper/Tests/CuHelperTests/AXTreePublicationIntegrationTests.swift b/native/cu-helper/Tests/CuHelperTests/AXTreePublicationIntegrationTests.swift index 58e0092b..aec36f1f 100644 --- a/native/cu-helper/Tests/CuHelperTests/AXTreePublicationIntegrationTests.swift +++ b/native/cu-helper/Tests/CuHelperTests/AXTreePublicationIntegrationTests.swift @@ -38,6 +38,10 @@ final class AXTreePublicationIntegrationTests: XCTestCase { try await verifyPublishedControl(mismatchedWindowTitle: false, exerciseDrags: true, wideWindow: true) } + func testVisibleCursorSurvivesRepeatedClicksAndTracksExposedBackgroundWindow() async throws { + try await verifyPublishedControl(mismatchedWindowTitle: false, exerciseVisualClicks: true) + } + func testTextScrollAndSecondaryMethodsReachTheSameOfficialReceiver() async throws { if ProcessInfo.processInfo.environment[Self.fixtureFlag] == "1" { try await runFixtureProcess(mismatchedWindowTitle: false, regularActivation: true, wideWindow: false) @@ -174,7 +178,7 @@ final class AXTreePublicationIntegrationTests: XCTestCase { try await waitUntil(description: "method fixture exit") { process.isTerminated } } - private func verifyPublishedControl(mismatchedWindowTitle: Bool, exerciseDrags: Bool = false, exerciseKeys: Bool = false, wideWindow: Bool = false) async throws { + private func verifyPublishedControl(mismatchedWindowTitle: Bool, exerciseDrags: Bool = false, exerciseKeys: Bool = false, wideWindow: Bool = false, exerciseVisualClicks: Bool = false) async throws { if ProcessInfo.processInfo.environment[Self.fixtureFlag] == "1" { try await runFixtureProcess( mismatchedWindowTitle: ProcessInfo.processInfo.environment[Self.fixtureMismatchedTitle] == "1", @@ -188,7 +192,7 @@ final class AXTreePublicationIntegrationTests: XCTestCase { AXIsProcessTrusted(), "Live AX publication requires Accessibility permission for the test runner" ) - if mismatchedWindowTitle || exerciseDrags { + if mismatchedWindowTitle || exerciseDrags || exerciseVisualClicks { try XCTSkipUnless( Capture.hasScreenRecordingPermission(), "Coordinate publication requires Screen Recording permission for the test runner" @@ -220,7 +224,7 @@ final class AXTreePublicationIntegrationTests: XCTestCase { Self.fixtureGesturePath: gestures.path, // A full-suite child enters the first test, so fixture modes must // travel with this launch rather than that test method's defaults. - Self.fixtureRegularActivation: exerciseKeys ? "1" : "0", + Self.fixtureRegularActivation: exerciseKeys || exerciseVisualClicks ? "1" : "0", Self.fixtureWideWindow: wideWindow ? "1" : "0", ]) { _, fixture in fixture } configuration.activates = false @@ -273,6 +277,10 @@ final class AXTreePublicationIntegrationTests: XCTestCase { let (_, clickedLine) = try publishedHandle(label: "Bold", state: clickedState) XCTAssertTrue(clickedLine.contains("Value: 1"), clickedLine) + if exerciseVisualClicks { + try await verifyVisibleClicks(cursor: cursor, router: router, process: process, gestures: gestures) + } + if exerciseKeys { let (canvasHandle, _) = try publishedHandle(label: "Drag fixture", state: clickedState) var phase = "canvas click" @@ -435,6 +443,114 @@ final class AXTreePublicationIntegrationTests: XCTestCase { try await waitUntil(description: "fixture exit") { process.isTerminated } } + private func verifyVisibleClicks( + cursor: VirtualCursor, router: CommandRouter, process: NSRunningApplication, gestures: URL + ) async throws { + let pid = process.processIdentifier + let captured = try await router.handle(cmd: "get_app_state", payload: .object([ + "pid": .int(Int(pid)), "disableDiff": .bool(true), + ])) + let shot = try XCTUnwrap(captured["screenshot"]) + let observed = try await AXTree.appState(pid: pid, disableDiff: true) + let (handle, _) = try publishedHandle(label: "Drag fixture", state: observed) + let frame = try XCTUnwrap(AXTree.record(pid: pid, index: handle.index)?.frameGlobal) + let point = CGPoint(x: frame.x + frame.w / 2, y: frame.y + frame.h / 2) + let x = (point.x - (try XCTUnwrap(shot["originX"]?.asDouble))) + * Double(try XCTUnwrap(shot["width"]?.asInt)) / (try XCTUnwrap(shot["pointWidth"]?.asDouble)) + let y = (point.y - (try XCTUnwrap(shot["originY"]?.asDouble))) + * Double(try XCTUnwrap(shot["height"]?.asInt)) / (try XCTUnwrap(shot["pointHeight"]?.asDouble)) + + // Observe real AppKit layers, not a mock callback or just a successful + // input result. A non-headless cursor that was never shown used to let + // every integration test skip the exact showClick branch that crashed. + // The first indexed action already preloaded this cursor's windows. + let overlays = NSApplication.shared.windows.filter { $0.ignoresMouseEvents && $0.level.rawValue == Int(CGShieldingWindowLevel()) } + XCTAssertFalse(overlays.isEmpty) + func ripples() -> [CALayer] { + overlays.flatMap { $0.contentView?.layer?.sublayers ?? [] }.filter { + $0.animation(forKey: "ripple") != nil || $0.animation(forKey: "rasterRipple") != nil + } + } + func click(button: String = "left", count: Int = 1) async throws { + let result = try await router.handle(cmd: "click", payload: .object([ + "pid": .int(Int(pid)), "x": .double(x), "y": .double(y), + "mouse_button": .string(button), "click_count": .int(count), + ])) + XCTAssertEqual(result, .bool(true)) + } + cursor.show() + XCTAssertTrue(process.activate(options: [])) + try await waitUntil(description: "disposable receiver is foreground") { + NSWorkspace.shared.frontmostApplication?.processIdentifier == pid + } + for step in 0..<12 { + // Retain the layers across await: a removed ripple's address can + // otherwise be reused by the next layer and look like no new ring. + let before = ripples() + try await click(button: step % 3 == 2 ? "right" : "left", count: step % 3 == 1 ? 2 : 1) + XCTAssertTrue(overlays.contains { $0.isVisible }) + XCTAssertTrue(ripples().contains { layer in !before.contains { $0 === layer } }, "Click \(step) must create visible feedback") + } + try await waitUntil(description: "16 mouse-up receipts from 12 single/double/right click commands") { + guard let data = try? Data(contentsOf: gestures), + let receipt = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false } + return receipt["completed"] as? Int == 16 + } + let receipt = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(contentsOf: gestures)) as? [String: Any]) + let events = try XCTUnwrap(receipt["events"] as? [[String: Any]]) + XCTAssertEqual(events.filter { $0["type"] as? UInt == NSEvent.EventType.rightMouseUp.rawValue }.count, 4) + XCTAssertEqual(receipt["unpaired"] as? Int, 0) + + // Put an independent host window to the right of the background + // receiver, then cover it, then uncover it. WindowServer supplies the + // actual occlusion evidence, as in the user's two-app layout. + let app = NSApplication.shared + let previousPolicy = app.activationPolicy() + app.setActivationPolicy(.regular) + let host = NSWindow(contentRect: NSRect(x: 660, y: 200, width: 240, height: 200), styleMask: [.titled], backing: .buffered, defer: false) + host.isReleasedWhenClosed = false + host.collectionBehavior = [.canJoinAllApplications] + host.title = "Disposable Computer Use host" + defer { + host.orderOut(nil) + host.close() + app.setActivationPolicy(previousPolicy) + WindowExposure.resetForTests() + } + host.makeKeyAndOrderFront(nil) + app.activate(ignoringOtherApps: true) + try await waitUntil(description: "disposable host is foreground") { + NSWorkspace.shared.frontmostApplication?.processIdentifier == getpid() + } + func expectFeedback(exposed: Bool) async throws { + try await waitUntil(description: exposed ? "target exposed" : "target covered") { + WindowExposure.resetForTests() + return WindowExposure.targetWindowExposed(at: point, targetPid: pid) == exposed + } + cursor.hide() + cursor.show() + try await click() + XCTAssertEqual(overlays.contains { $0.isVisible }, exposed) + XCTAssertEqual(!ripples().isEmpty, exposed) + XCTAssertEqual(NSWorkspace.shared.frontmostApplication?.processIdentifier, getpid()) + } + try await expectFeedback(exposed: true) + host.setFrame(NSRect(x: point.x - 40, y: CGDisplayBounds(CGMainDisplayID()).height - point.y - 40, width: 240, height: 200), display: true) + try await expectFeedback(exposed: false) + host.setFrame(NSRect(x: 660, y: 200, width: 240, height: 200), display: true) + try await expectFeedback(exposed: true) + try await waitUntil(description: "three background clicks received, including covered target") { + guard let data = try? Data(contentsOf: gestures), + let receipt = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false } + return receipt["completed"] as? Int == 19 + } + cursor.hide() + cursor.showClick(at: point, kind: .single) + XCTAssertTrue(ripples().isEmpty, "Turn end must clear feedback") + XCTAssertFalse(overlays.contains { $0.isVisible }) + print("[native-visible-click-smoke] observed 15 commands, 19 received clicks; checked foreground/exposed/covered/re-exposed/hidden feedback") + } + private func runFixtureProcess(mismatchedWindowTitle: Bool, regularActivation: Bool, wideWindow: Bool) async throws { let environment = ProcessInfo.processInfo.environment let readyPath = try XCTUnwrap(environment[Self.fixtureReadyPath]) @@ -637,6 +753,9 @@ private final class DragReceiptView: NSView { record(event) } + override func rightMouseDown(with event: NSEvent) { mouseDown(with: event) } + override func rightMouseUp(with event: NSEvent) { mouseUp(with: event) } + private func record(_ event: NSEvent) { events.append([ "type": event.type.rawValue, diff --git a/native/cu-helper/build.sh b/native/cu-helper/build.sh index a6267f44..6b54a0aa 100755 --- a/native/cu-helper/build.sh +++ b/native/cu-helper/build.sh @@ -517,6 +517,12 @@ verify_relocated_cursor_resources() ( local expected_directory="$probe_app/Contents/Resources/cu-helper_cc-haha-computer-use.bundle/LensSequence" [ -d "$expected_directory" ] || die "Cursor resource probe package is missing $expected_directory" expected_directory="$(cd "$expected_directory" && pwd -P)" + local canonical_app + canonical_app="$(cd "$probe_app" && pwd -P)" + case "$expected_directory" in + "$canonical_app"/*) ;; + *) die "Cursor resource probe found resources outside relocated package: $expected_directory" ;; + esac [ "$resource_directory" = "$expected_directory" ] \ || die "Cursor resource probe loaded '$resource_directory' instead of relocated package '$expected_directory'" log "verified: relocated cursor resources ($resource_directory)" diff --git a/native/cu-helper/build.test.ts b/native/cu-helper/build.test.ts index d7610336..ef467e50 100644 --- a/native/cu-helper/build.test.ts +++ b/native/cu-helper/build.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' @@ -8,11 +8,26 @@ const productIcon = path.resolve(import.meta.dirname, '../../desktop/src-tauri/i const fixtureDirectories: string[] = [] const resourceBundleName = 'cu-helper_cc-haha-computer-use.bundle' +function runFixtureCommand(command: string[], options: { cwd?: string, env?: Record } = {}) { + // File-backed output also works on Bun versions where test subprocesses + // receive closed pipe descriptors (even /bin/echo exits 1 with no output). + // Keep the real shell result and diagnostics; never turn that failure into a skip. + const directory = mkdtempSync(path.join(tmpdir(), 'cu-helper-fixture-output-')) + fixtureDirectories.push(directory) + const stdout = path.join(directory, 'stdout') + const stderr = path.join(directory, 'stderr') + const result = Bun.spawnSync(command, { + ...options, + stdin: 'ignore', stdout: Bun.file(stdout), stderr: Bun.file(stderr), + }) + return { exitCode: result.exitCode, stdout: readFileSync(stdout), stderr: readFileSync(stderr) } +} + function resolveArchitectureSpecificBuildPaths(arch: 'arm64' | 'x86_64') { const directory = mkdtempSync(path.join(tmpdir(), 'cu-helper-build-path-')) fixtureDirectories.push(directory) const binDir = path.join(directory, arch, `${arch}-apple-macosx`, 'release') - const result = Bun.spawnSync([ + const result = runFixtureCommand([ 'bash', '-c', ` @@ -58,7 +73,7 @@ function wrapFixtureApp(options: { missingIcon?: boolean, missingResources?: boo writeFileSync(path.join(resourceBundle, 'LensSequence', 'README.md'), 'optional frames fixture') } - const result = Bun.spawnSync([ + const result = runFixtureCommand([ 'bash', '-c', ` @@ -96,7 +111,7 @@ wrap_app } } -function probeFixtureApp(mode: 'packaged' | 'build-path' | 'invalid-json' | 'crash', crossArch = false) { +function probeFixtureApp(mode: 'packaged' | 'build-path' | 'external-symlink' | 'invalid-json' | 'crash', crossArch = false) { const directory = mkdtempSync(path.join(tmpdir(), 'cu-helper-resource-probe-')) fixtureDirectories.push(directory) const app = path.join(directory, 'source.app') @@ -106,6 +121,11 @@ function probeFixtureApp(mode: 'packaged' | 'build-path' | 'invalid-json' | 'cra writeFileSync(path.join(app, 'Contents', 'Resources', resourceBundleName, 'LensSequence', 'README.md'), 'optional frames fixture') const buildTreeResources = path.join(directory, 'build-tree', resourceBundleName, 'LensSequence') mkdirSync(buildTreeResources, { recursive: true }) + if (mode === 'external-symlink') { + const lensDirectory = path.join(app, 'Contents', 'Resources', resourceBundleName, 'LensSequence') + rmSync(lensDirectory, { recursive: true }) + symlinkSync(buildTreeResources, lensDirectory) + } writeFileSync(path.join(app, 'Contents', 'Resources', 'outside-resource-path'), buildTreeResources) writeFileSync(binary, `#!/bin/bash set -eu @@ -119,7 +139,7 @@ esac printf '{"resourceDirectory":"%s","frameCount":0,"proceduralFallback":true}\\n' "$resource_dir" `) chmodSync(binary, 0o755) - const result = Bun.spawnSync([ + const result = runFixtureCommand([ 'bash', '-c', ` source "$1" APP_PATH="$2" @@ -140,11 +160,12 @@ verify_relocated_cursor_resources } function resolveTimestampArgument(identity: string, mode = 'auto') { - const result = Bun.spawnSync([ + const result = runFixtureCommand([ 'bash', '-c', [ 'source "$1"', + 'security() { return 1; }', 'SIGN_IDENTITY="$2"', 'CU_HELPER_TIMESTAMP_MODE="$3"', 'resolve_timestamp_mode', @@ -163,7 +184,7 @@ function resolveTimestampArgument(identity: string, mode = 'auto') { } function resolveIdentityWithOnlyDeveloperId() { - const result = Bun.spawnSync([ + const result = runFixtureCommand([ 'bash', '-c', [ @@ -239,7 +260,7 @@ describe.skipIf(process.platform !== 'darwin')('cu-helper permission-list app ic const result = wrapFixtureApp() expect(result.exitCode).toBe(0) - const plist = Bun.spawnSync([ + const plist = runFixtureCommand([ '/usr/bin/plutil', '-convert', 'json', '-o', '-', path.join(result.contents, 'Info.plist'), ]) @@ -292,11 +313,12 @@ describe.skipIf(process.platform !== 'darwin')('cu-helper relocated resource pro expect(result.leftovers).toEqual([]) }) - test.each(['build-path', 'invalid-json', 'crash'] as const)('rejects %s instead of accepting a false resource-load success', mode => { + test.each(['build-path', 'external-symlink', 'invalid-json', 'crash'] as const)('rejects %s instead of accepting a false resource-load success', mode => { const result = probeFixtureApp(mode) expect(result.exitCode).not.toBe(0) expect(result.stderr).toContain('Cursor resource probe') if (mode === 'build-path') expect(result.stderr).toContain('instead of relocated package') + if (mode === 'external-symlink') expect(result.stderr).toContain('outside relocated package') expect(result.leftovers).toEqual([]) }) diff --git a/package.json b/package.json index 390e1507..080ea5a8 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "perf:local-index:10k": "bun run scripts/perf/local-index-benchmark.ts --sessions 10000 --runs 20", "check:pr": "bun run scripts/pr/check-pr.ts", "check:impact": "bun run scripts/pr/impact-report.ts", - "check:policy": "bun test ./scripts/pr/bun-test-filter.test.ts ./scripts/pr/change-policy.test.ts ./scripts/pr/changed-files.test.ts ./scripts/pr/dead-imports.test.ts ./scripts/pr/module-graph.test.ts ./scripts/pr/pr-triage-workflow.test.ts ./scripts/pr/pr-quality-workflow.test.ts ./scripts/pr/release-workflow.test.ts ./scripts/pr/quality-contract.test.ts ./scripts/pr/test-environment.test.ts ./scripts/pr/run-swift-checks.test.ts ./scripts/release-update-metadata.test.ts ./scripts/git-hooks/install.test.ts ./scripts/quality-gate/quarantine.test.ts ./scripts/quality-gate/coverage.test.ts ./scripts/quality-gate/provider-smoke/execute.test.ts ./scripts/quality-gate/desktop-smoke/execute.test.ts ./scripts/quality-gate/providerTargets.test.ts ./scripts/quality-gate/runner.test.ts ./scripts/quality-gate/sandbox.test.ts ./scripts/quality-gate/agent-flow/scenarios.test.ts ./scripts/quality-gate/agent-flow/live.test.ts ./scripts/quality-gate/desktop-smoke/deterministic.test.ts ./scripts/quality-gate/computer-use-live-smoke.test.ts ./scripts/quality-gate/computer-use-signed-chain.test.ts", + "check:policy": "bun test ./scripts/pr/bun-test-filter.test.ts ./scripts/pr/change-policy.test.ts ./scripts/pr/changed-files.test.ts ./scripts/pr/dead-imports.test.ts ./scripts/pr/module-graph.test.ts ./scripts/pr/pr-triage-workflow.test.ts ./scripts/pr/pr-quality-workflow.test.ts ./scripts/pr/release-workflow.test.ts ./scripts/pr/quality-contract.test.ts ./scripts/pr/test-environment.test.ts ./scripts/pr/run-swift-checks.test.ts ./scripts/quality-gate/package-smoke/index.test.ts ./scripts/release-update-metadata.test.ts ./scripts/git-hooks/install.test.ts ./scripts/quality-gate/quarantine.test.ts ./scripts/quality-gate/coverage.test.ts ./scripts/quality-gate/provider-smoke/execute.test.ts ./scripts/quality-gate/desktop-smoke/execute.test.ts ./scripts/quality-gate/providerTargets.test.ts ./scripts/quality-gate/runner.test.ts ./scripts/quality-gate/sandbox.test.ts ./scripts/quality-gate/agent-flow/scenarios.test.ts ./scripts/quality-gate/agent-flow/live.test.ts ./scripts/quality-gate/desktop-smoke/deterministic.test.ts ./scripts/quality-gate/computer-use-live-smoke.test.ts ./scripts/quality-gate/computer-use-signed-chain.test.ts", "check:server": "bun run scripts/pr/run-server-tests.ts", "check:provider-contract": "bun run scripts/pr/run-provider-contract-tests.ts", "check:chat-contract": "bun run scripts/pr/run-chat-contract-tests.ts", diff --git a/scripts/pr/change-policy.test.ts b/scripts/pr/change-policy.test.ts index 858a51df..a6e3903a 100644 --- a/scripts/pr/change-policy.test.ts +++ b/scripts/pr/change-policy.test.ts @@ -93,6 +93,20 @@ describe('evaluateChangePolicy', () => { expect(result.checks.desktopNative).toBe(true) }) + test.each([ + 'native/cu-helper/Sources/cu-helper/VirtualCursor.swift', + 'native/cu-helper/Tests/CuHelperTests/VirtualCursorResourceTests.swift', + 'native/cu-helper/Sources/cu-helper/Resources/LensSequence/frame_000.png', + 'native/cu-helper/Package.swift', + 'native/cu-helper/build.sh', + ])('requires the native and macOS Swift jobs for a standalone change to %s', file => { + const result = evaluateChangePolicy([file]) + expect(result.areas).toEqual(['desktop']) + expect(result.checks.desktopNative).toBe(true) + expect(result.checks.desktop).toBe(false) + expect(result.blocked).toBe(false) + }) + test('routes provider runtime changes to the offline provider contract', () => { const result = evaluateChangePolicy([ 'src/server/services/providerRuntimeEnv.ts', diff --git a/scripts/pr/change-policy.ts b/scripts/pr/change-policy.ts index a193334a..d1053779 100644 --- a/scripts/pr/change-policy.ts +++ b/scripts/pr/change-policy.ts @@ -227,7 +227,7 @@ function areasForPath(path: string): ChangeArea[] { return [] } - if (path.startsWith('desktop/')) { + if (path.startsWith('desktop/') || path.startsWith('native/')) { areas.add('desktop') } @@ -364,6 +364,7 @@ export function evaluateChangePolicy( file.startsWith('desktop/src/') || desktopWebExactPaths.has(file) )) const touchesDesktopNative = selectionFiles.some((file) => ( + file.startsWith('native/') || file.startsWith('desktop/electron/') || file.startsWith('desktop/scripts/') || file.startsWith('desktop/src-tauri/') || diff --git a/scripts/pr/pr-quality-workflow.test.ts b/scripts/pr/pr-quality-workflow.test.ts index a4fe4b3e..96ea1ebe 100644 --- a/scripts/pr/pr-quality-workflow.test.ts +++ b/scripts/pr/pr-quality-workflow.test.ts @@ -116,6 +116,7 @@ describe('PR quality workflow', () => { expect(gate.needs).toContain('desktop-native-checks') expect(packageJson.scripts['check:swift']).toBe('bun run scripts/pr/run-swift-checks.ts') expect(packageJson.scripts['check:policy']).toContain('scripts/pr/run-swift-checks.test.ts') + expect(packageJson.scripts['check:policy']).toContain('scripts/quality-gate/package-smoke/index.test.ts') for (const command of ['check:swift', 'build:sidecars', 'test:compiled-sidecar-smoke', 'check:electron', 'electron:package:dir', 'test:package-smoke:current']) { expect(packageJson.scripts['check:native']).toContain(`bun run ${command}`) } diff --git a/scripts/pr/run-swift-checks.test.ts b/scripts/pr/run-swift-checks.test.ts index 7b624910..67a5da46 100644 --- a/scripts/pr/run-swift-checks.test.ts +++ b/scripts/pr/run-swift-checks.test.ts @@ -10,25 +10,37 @@ describe('Swift platform checks', () => { expect(run).not.toHaveBeenCalled() }) - test.each([0, 7])('runs the full macOS package in isolation and propagates exit %s', async exitCode => { + test.each([ + { swiftExit: 0, packagingExit: 0, expected: 0, calls: 2 }, + { swiftExit: 7, packagingExit: 0, expected: 7, calls: 1 }, + { swiftExit: 0, packagingExit: 9, expected: 9, calls: 2 }, + ])('runs Swift and shell packaging regressions in isolation and propagates their result: %j', async scenario => { let home = '' + const commands: string[][] = [] const run = mock(async (command: string[], options: { cwd: string; env: Record }) => { + commands.push(command) home = options.env.HOME! - expect(command.slice(0, 2)).toEqual(['swift', 'test']) - expect(command).toContain('--enable-xctest') - expect(command[command.indexOf('--package-path') + 1]).toBe(join(options.cwd, 'native/cu-helper')) - expect(command[command.indexOf('--scratch-path') + 1]).toBe(join(home, 'build')) + if (command[0] === 'swift') { + expect(command.slice(0, 2)).toEqual(['swift', 'test']) + expect(command).toContain('--enable-xctest') + expect(command[command.indexOf('--package-path') + 1]).toBe(join(options.cwd, 'native/cu-helper')) + expect(command[command.indexOf('--scratch-path') + 1]).toBe(join(home, 'build')) + } else { + expect(command).toEqual(['bun', 'test', join(options.cwd, 'native/cu-helper/build.test.ts')]) + expect(commands[0]?.[0]).toBe('swift') + } expect(isAbsolute(options.cwd)).toBe(true) expect(home).not.toBe(process.env.HOME) expect(options.env.CLAUDE_CONFIG_DIR).toBe(join(home, '.claude')) + expect(options.env.CFFIXED_USER_HOME).toBe(home) expect(options.env.ANTHROPIC_API_KEY).toBeUndefined() expect(options.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined() expect(options.env.GH_TOKEN).toBeUndefined() writeFileSync(join(home, 'cleanup-fixture'), 'disposable Swift test output') - return exitCode + return command[0] === 'swift' ? scenario.swiftExit : scenario.packagingExit }) - expect(await runSwiftChecks({ platform: 'darwin', run })).toBe(exitCode) - expect(run).toHaveBeenCalledTimes(1) + expect(await runSwiftChecks({ platform: 'darwin', run })).toBe(scenario.expected) + expect(run).toHaveBeenCalledTimes(scenario.calls) expect(existsSync(home)).toBe(false) }) diff --git a/scripts/pr/run-swift-checks.ts b/scripts/pr/run-swift-checks.ts index f0283541..79c43189 100644 --- a/scripts/pr/run-swift-checks.ts +++ b/scripts/pr/run-swift-checks.ts @@ -27,12 +27,20 @@ export async function runSwiftChecks(options: { return await child.exited }) try { - return await run([ + const env = createSandboxedTestEnvironment(sandboxHome, { CFFIXED_USER_HOME: sandboxHome }) + const swiftExit = await run([ 'swift', 'test', '--package-path', join(root, 'native/cu-helper'), '--scratch-path', join(sandboxHome, 'build'), '--enable-xctest', - ], { cwd: root, env: createSandboxedTestEnvironment(sandboxHome) }) + ], { cwd: root, env }) + if (swiftExit !== 0) return swiftExit + // The macOS PR job and local check:native share this entrypoint. Keep the + // shell packaging/probe regressions here so they cannot silently remain + // unexecuted while Swift XCTest alone reports the native lane green. + return await run([ + 'bun', 'test', join(root, 'native/cu-helper/build.test.ts'), + ], { cwd: root, env }) } finally { rmSync(sandboxHome, { recursive: true, force: true }) } diff --git a/scripts/quality-gate/package-smoke/index.test.ts b/scripts/quality-gate/package-smoke/index.test.ts index 28f11f05..0db8b948 100644 --- a/scripts/quality-gate/package-smoke/index.test.ts +++ b/scripts/quality-gate/package-smoke/index.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, dirname, join } from 'node:path' import { @@ -7,12 +7,18 @@ import { currentPackageSmokePlatform, } from './current' import { - inspectPackagedArtifacts, + inspectPackagedArtifacts as inspectPackage, parseCodesignMetadata, parseMachOMinimumMacosVersions, parsePackageSmokeArgs, } from './index' +// These fixtures contain synthetic Mach-O headers, not runnable executables. +// Resource execution has dedicated cases below with an explicit runner. +function inspectPackagedArtifacts(rootDir: string, options: Parameters[1]) { + return inspectPackage(rootDir, { hostPlatform: 'linux', ...options }) +} + function createRepoRoot() { const rootDir = mkdtempSync(join(tmpdir(), 'package-smoke-')) mkdirSync(join(rootDir, 'desktop'), { recursive: true }) @@ -55,6 +61,9 @@ function writeFile(rootDir: string, relativePath: string, content: string | Uint 'LSMinimumSystemVersion14.4', ) writeFileSync(join(helperRoot, 'MacOS', 'cc-haha-computer-use'), content) + const sequence = join(helperRoot, 'Resources', 'cu-helper_cc-haha-computer-use.bundle', 'LensSequence') + mkdirSync(sequence, { recursive: true }) + writeFileSync(join(sequence, 'README.md'), 'Optional cursor frames are absent in this fixture.') } } } @@ -129,6 +138,118 @@ describe('package smoke args', () => { }) }) +describe('final macOS helper cursor resource verification', () => { + const executionLabel = 'macOS relocated cu-helper cursor resource execution' + const structureLabel = 'macOS cu-helper cursor resource directory' + const sequenceRelative = 'Contents/Resources/cu-helper_cc-haha-computer-use.bundle/LensSequence' + + function fixture(arch: 'arm64' | 'x64' = 'arm64') { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + const app = 'desktop/build-artifacts/electron/mac/Claude Code Haha.app' + const resources = `${app}/Contents/Resources` + const binaries = `${resources}/app.asar.unpacked/src-tauri/binaries` + const triple = arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin' + const pty = `${resources}/app.asar.unpacked/node_modules/node-pty` + writeFile(rootDir, `${app}/Contents/Info.plist`) + writeFile(rootDir, `${app}/Contents/MacOS/Claude Code Haha`, thinMachO(arch)) + writeFile(rootDir, `${resources}/app.asar`) + writeFile(rootDir, `${resources}/app.asar.unpacked/dist/index.html`) + writeFile(rootDir, `${binaries}/claude-sidecar-${triple}`, thinMachO(arch)) + writeFile(rootDir, `${pty}/package.json`) + writeFile(rootDir, `${pty}/prebuilds/darwin-${arch}/pty.node`, thinMachO(arch)) + writeFile(rootDir, `${pty}/prebuilds/darwin-${arch}/spawn-helper`, thinMachO(arch)) + const helper = join(rootDir, binaries, 'cc-haha-computer-use.app') + return { rootDir, helper, sequence: join(helper, sequenceRelative) } + } + + test.each(['arm64', 'x64'] as const)('executes the %s final helper from a disposable standalone path with isolated state', async arch => { + const source = fixture(arch) + let temporaryRoot = '' + const report = await inspectPackage(source.rootDir, { + platform: 'macos', arch, packageKind: 'dir', hostPlatform: 'macos', hostArch: arch, + commandRunner: (command, args, options) => { + expect(args).toEqual(['--probe-cursor-resources']) + expect(command.startsWith(source.helper)).toBe(false) + expect(command).toContain('Relocated Helper.app/Contents/MacOS/cc-haha-computer-use') + expect(options?.timeout).toBe(10_000) + expect(options?.maxBuffer).toBe(1024 * 1024) + temporaryRoot = options!.cwd + expect(options?.env.HOME).toBe(join(temporaryRoot, 'home')) + expect(options?.env.CFFIXED_USER_HOME).toBe(options?.env.HOME) + expect(options?.env.CLAUDE_CONFIG_DIR).toBe(join(temporaryRoot, 'home', '.claude')) + expect(options?.env.OPENAI_API_KEY).toBeUndefined() + const resourceDirectory = join(dirname(dirname(command)), 'Resources/cu-helper_cc-haha-computer-use.bundle/LensSequence') + expect(existsSync(join(resourceDirectory, 'README.md'))).toBe(true) + return { status: 0, stdout: JSON.stringify({ resourceDirectory, frameCount: 0, proceduralFallback: true }) } + }, + }) + expect(report.passed).toBe(true) + expect(report.passedChecks.some(check => check.label === executionLabel)).toBe(true) + expect(existsSync(temporaryRoot)).toBe(false) + expect(existsSync(source.sequence)).toBe(true) + }) + + test.each(['missing', 'file', 'external-symlink', 'external-frame-symlink'] as const)('rejects a final package with %s cursor resources before any execution', async mode => { + const source = fixture() + let executed = false + if (mode !== 'external-frame-symlink') rmSync(source.sequence, { recursive: true }) + if (mode === 'file') writeFileSync(source.sequence, 'not a directory') + if (mode === 'external-symlink') symlinkSync(source.rootDir, source.sequence, 'dir') + if (mode === 'external-frame-symlink') { + const external = join(source.rootDir, 'build-tree-frame.png') + writeFileSync(external, 'outside the packaged helper') + symlinkSync(external, join(source.sequence, 'frame_0.png')) + } + const report = await inspectPackage(source.rootDir, { + platform: 'macos', arch: 'arm64', packageKind: 'dir', hostPlatform: 'macos', hostArch: 'arm64', + commandRunner: () => { executed = true; throw new Error('must not run an invalid package') }, + }) + expect(executed).toBe(false) + expect(report.passed).toBe(false) + expect(report.missingChecks.some(check => check.label === structureLabel)).toBe(true) + expect(report.passedChecks.some(check => check.label === executionLabel)).toBe(false) + }) + + test.each(['crash', 'invalid-json', 'external-directory', 'invalid-frames'] as const)('fails the final package when the resource diagnostic reports %s', async mode => { + const source = fixture() + let temporaryRoot = '' + const report = await inspectPackage(source.rootDir, { + platform: 'macos', arch: 'arm64', packageKind: 'dir', hostPlatform: 'macos', hostArch: 'arm64', + commandRunner: (command, _args, options) => { + temporaryRoot = options!.cwd + if (mode === 'crash') return { status: null, stderr: 'terminated by signal' } + if (mode === 'invalid-json') return { status: 0, stdout: 'not JSON' } + const resourceDirectory = mode === 'external-directory' ? source.sequence + : join(dirname(dirname(command)), 'Resources/cu-helper_cc-haha-computer-use.bundle/LensSequence') + return { status: 0, stdout: JSON.stringify({ + resourceDirectory, frameCount: mode === 'invalid-frames' ? -1 : 0, proceduralFallback: true, + }) } + }, + }) + expect(report.passed).toBe(false) + expect(report.missingChecks.some(check => check.label === executionLabel)).toBe(true) + expect(existsSync(temporaryRoot)).toBe(false) + }) + + test.each([ + { platform: 'macos', arch: 'arm64', note: 'target x86_64, host arm64' }, + { platform: 'linux', arch: 'x64', note: 'host platform is linux' }, + ])('records a skipped execution on $platform/$arch without counting it as a passed probe', async host => { + const source = fixture('x64') + let executed = false + const report = await inspectPackage(source.rootDir, { + platform: 'macos', arch: 'x64', packageKind: 'dir', hostPlatform: host.platform, hostArch: host.arch, + commandRunner: () => { executed = true; return { status: 1 } }, + }) + expect(report.passed).toBe(true) + expect(executed).toBe(false) + expect(report.passedChecks.some(check => check.label === executionLabel)).toBe(false) + expect(report.notes.join('\n')).toContain(`SKIPPED: ${executionLabel}`) + expect(report.notes.join('\n')).toContain(host.note) + }) +}) + describe('packaged artifact inspection', () => { test('passes macOS bundle structure checks and records optional archive artifacts', async () => { const rootDir = createRepoRoot() diff --git a/scripts/quality-gate/package-smoke/index.ts b/scripts/quality-gate/package-smoke/index.ts index 07787a97..dbc6ad71 100644 --- a/scripts/quality-gate/package-smoke/index.ts +++ b/scripts/quality-gate/package-smoke/index.ts @@ -1,8 +1,10 @@ #!/usr/bin/env bun -import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs' import { spawnSync } from 'node:child_process' -import { dirname, join, relative, resolve } from 'node:path' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { createSandboxedTestEnvironment } from '../../pr/test-environment' export type PackageSmokePlatform = 'macos' | 'windows' | 'linux' export type PackageSmokeArch = 'x64' | 'arm64' @@ -22,6 +24,7 @@ type InspectOptions = { packageKind?: PackageKind commandRunner?: PackageSmokeCommandRunner hostPlatform?: string + hostArch?: string } export type PackageSmokeArgs = { @@ -60,7 +63,14 @@ type PackageSmokeCommandResult = { stderr?: string } -type PackageSmokeCommandRunner = (command: string, args: string[]) => PackageSmokeCommandResult +type PackageSmokeCommandOptions = { + cwd: string + env: Record + timeout: number + maxBuffer: number +} + +type PackageSmokeCommandRunner = (command: string, args: string[], options?: PackageSmokeCommandOptions) => PackageSmokeCommandResult function usage() { return 'Usage: bun run test:package-smoke --platform [--arch ] [--package-kind ] [--artifacts-dir ] [--require-macos-gatekeeper]' @@ -542,15 +552,109 @@ function collectDiagnosticLines(output: string, limit = 3) { .slice(0, limit) } -function defaultCommandRunner(command: string, args: string[]): PackageSmokeCommandResult { +function defaultCommandRunner(command: string, args: string[], options?: PackageSmokeCommandOptions): PackageSmokeCommandResult { const result = spawnSync(command, args, { encoding: 'utf8', + ...options, }) return { status: result.status, stdout: result.stdout ?? '', - stderr: result.stderr ?? '', + stderr: result.error?.message ?? result.stderr ?? '', + } +} + +function assertCursorResourcesContained(helperApp: string, directory: string) { + const app = realpathSync(helperApp) + const pending = [directory] + const visited = new Set() + while (pending.length > 0) { + const target = pending.pop()! + const canonical = realpathSync(target) + const withinApp = relative(app, canonical) + if (isAbsolute(withinApp) || withinApp === '..' || withinApp.startsWith('../')) { + throw new Error(`cursor resource escapes the helper app: ${target}`) + } + if (visited.has(canonical)) continue + visited.add(canonical) + const entry = statSync(target) + if (entry.isDirectory()) pending.push(...readdirSync(target).map(name => join(target, name))) + else if (!entry.isFile()) throw new Error(`cursor resource is not a regular file: ${target}`) + } +} + +function addMacosCursorResourceCheck( + report: PackageSmokeReport, + rootDir: string, + helperApp: string, + options: InspectOptions, +) { + const sequenceRelative = 'Contents/Resources/cu-helper_cc-haha-computer-use.bundle/LensSequence' + const sourceDirectory = join(helperApp, sequenceRelative) + const structureLabel = 'macOS cu-helper cursor resource directory' + const executionLabel = 'macOS relocated cu-helper cursor resource execution' + const record = { label: executionLabel, path: toRelative(rootDir, helperApp) } + let probeRoot: string | undefined + try { + if (!statSync(sourceDirectory).isDirectory()) throw new Error('LensSequence is not a directory') + assertCursorResourcesContained(helperApp, sourceDirectory) + report.passedChecks.push({ label: structureLabel, path: toRelative(rootDir, sourceDirectory) }) + } catch (error) { + report.missingChecks.push({ label: structureLabel, path: toRelative(rootDir, sourceDirectory) }) + report.notes.push(`${structureLabel} failed: ${error instanceof Error ? error.message : String(error)}`) + return + } + if (report.hostPlatform !== 'macos') { + report.notes.push(`SKIPPED: ${executionLabel}; host platform is ${report.hostPlatform}.`) + return + } + const hostArch = options.hostArch ?? process.arch + const hostMachOArch = hostArch === 'x64' ? 'x86_64' : hostArch + const targetArch = report.arch === 'x64' ? 'x86_64' : report.arch + if (targetArch && targetArch !== hostMachOArch) { + report.notes.push(`SKIPPED: ${executionLabel}; target ${targetArch}, host ${hostMachOArch}. Only package structure was checked.`) + return + } + try { + const inner = 'Contents/MacOS/cc-haha-computer-use' + const architectures = parseMachOArchitectures(readFileSync(join(helperApp, inner))) + if (!architectures.includes(hostMachOArch as MachOArch)) { + if (architectures.length === 0) throw new Error('helper has no recognized Mach-O architecture') + report.notes.push(`SKIPPED: ${executionLabel}; binary ${architectures.join(',')}, host ${hostMachOArch}. Only package structure was checked.`) + return + } + probeRoot = mkdtempSync(join(tmpdir(), 'cc-haha-packaged-cursor-')) + const app = join(probeRoot, 'Relocated Helper.app') + cpSync(helperApp, app, { recursive: true, verbatimSymlinks: true }) + assertCursorResourcesContained(app, join(app, sequenceRelative)) + const expected = realpathSync(join(app, sequenceRelative)) + const home = join(probeRoot, 'home') + mkdirSync(home) + const env = createSandboxedTestEnvironment(home, { + PATH: '/usr/bin:/bin:/usr/sbin:/sbin', CFFIXED_USER_HOME: home, + }, {}) + const result = (options.commandRunner ?? defaultCommandRunner)(join(app, inner), ['--probe-cursor-resources'], { + cwd: probeRoot, env, timeout: 10_000, maxBuffer: 1024 * 1024, + }) + if (result.status !== 0) throw new Error(`probe exited with status ${result.status}: ${result.stderr ?? ''}`) + const resources = JSON.parse(result.stdout ?? '') as Record + if (!resources || typeof resources.resourceDirectory !== 'string' + || !isAbsolute(resources.resourceDirectory) + || realpathSync(resources.resourceDirectory) !== expected) { + throw new Error('probe did not load resources from the relocated final package') + } + if (!Number.isSafeInteger(resources.frameCount) || (resources.frameCount as number) < 0 + || resources.proceduralFallback !== (resources.frameCount === 0)) { + throw new Error('probe returned invalid cursor frame/fallback diagnostics') + } + report.passedChecks.push(record) + report.notes.push(`${executionLabel}: executed ${hostMachOArch}, frames=${resources.frameCount}, proceduralFallback=${resources.proceduralFallback}. No GUI or input was requested.`) + } catch (error) { + report.missingChecks.push(record) + report.notes.push(`${executionLabel} failed: ${error instanceof Error ? error.message : String(error)}`) + } finally { + if (probeRoot) rmSync(probeRoot, { recursive: true, force: true }) } } @@ -841,6 +945,7 @@ function inspectMacosArtifacts(rootDir: string, report: PackageSmokeReport, opti addPresenceCheck(report, rootDir, 'macOS cu-helper app bundle', helperApp) addPresenceCheck(report, rootDir, 'macOS cu-helper Info.plist', helperInfoPlist) addPresenceCheck(report, rootDir, 'macOS cu-helper executable', helperExecutable) + addMacosCursorResourceCheck(report, rootDir, helperApp, options) if (existsSync(helperInfoPlist)) addHelperMinimumSystemCheck(report, rootDir, helperInfoPlist) addBundledRipgrepLicenseChecks(report, rootDir, sidecarDir, 'macOS') addMatchCheck( @@ -907,7 +1012,7 @@ function inspectMacosArtifacts(rootDir: string, report: PackageSmokeReport, opti ) } - report.notes.push('No GUI launch was attempted. This command only inspects packaged bundle structure and key unpacked resources.') + report.notes.push('No GUI launch was attempted. Matching macOS helper architectures also run an input-free resource probe from a disposable copy of the final package.') if (options.requireMacosGatekeeper) { if (report.arch) { const targetTriple = report.arch === 'arm64'