fix(computer-use): enforce cursor regression and package gates

Route native-only changes through macOS checks and verify relocated cursor resources in final packages. Reject resources that escape the app and exercise visible click feedback against a disposable native receiver.

Connect the regressions to required checks and capture shell fixture output through temporary files.
This commit is contained in:
程序员阿江(Relakkes)
2026-09-11 15:53:22 +08:00
parent a0a8fd4b45
commit 0bcbfe6921
11 changed files with 441 additions and 32 deletions
@@ -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,
+6
View File
@@ -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)"
+31 -9
View File
@@ -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<string, string | undefined> } = {}) {
// 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([])
})
+1 -1
View File
@@ -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",
+14
View File
@@ -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',
+2 -1
View File
@@ -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/') ||
+1
View File
@@ -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}`)
}
+20 -8
View File
@@ -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<string, string> }) => {
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)
})
+10 -2
View File
@@ -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 })
}
@@ -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<typeof inspectPackage>[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
'<plist><dict><key>LSMinimumSystemVersion</key><string>14.4</string></dict></plist>',
)
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()
+111 -6
View File
@@ -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<string, string>
timeout: number
maxBuffer: number
}
type PackageSmokeCommandRunner = (command: string, args: string[], options?: PackageSmokeCommandOptions) => PackageSmokeCommandResult
function usage() {
return 'Usage: bun run test:package-smoke --platform <macos|windows|linux> [--arch <x64|arm64>] [--package-kind <auto|dir|release>] [--artifacts-dir <path>] [--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<string>()
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<string, unknown>
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'