Files
claude-code-haha/native/cu-helper/build.test.ts
T
程序员阿江(Relakkes) 0bcbfe6921 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.
2026-09-11 15:53:22 +08:00

333 lines
12 KiB
TypeScript

import { afterEach, describe, expect, test } from 'bun:test'
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
const buildScript = path.resolve(import.meta.dirname, 'build.sh')
const productIcon = path.resolve(import.meta.dirname, '../../desktop/src-tauri/icons/icon.icns')
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 = runFixtureCommand([
'bash',
'-c',
`
source "$1"
ARCH="$2"
BUILD_DIR="$3"
SWIFT_SCRATCH_PATH="$BUILD_DIR/$ARCH"
EXPECTED_BIN_DIR="$4"
swift() {
printf '%s\\n' "$EXPECTED_BIN_DIR"
}
resolve_build_paths
printf '%s\\n%s\\n%s\\n%s\\n' "$BIN_DIR" "$BIN_PATH" "$APP_PATH" "$RESOURCE_BUNDLE_PATH"
`,
'cu-helper-build-path-test',
buildScript,
arch,
directory,
binDir,
])
return {
exitCode: result.exitCode,
lines: result.stdout.toString().trim().split('\n'),
stderr: result.stderr.toString(),
binDir,
}
}
afterEach(() => {
for (const directory of fixtureDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
function wrapFixtureApp(options: { missingIcon?: boolean, missingResources?: boolean } = {}) {
const directory = mkdtempSync(path.join(tmpdir(), 'cu-helper-app-icon-'))
fixtureDirectories.push(directory)
writeFileSync(path.join(directory, 'fixture-binary'), 'fixture executable')
const resourceBundle = path.join(directory, resourceBundleName)
if (!options.missingResources) {
mkdirSync(path.join(resourceBundle, 'LensSequence'), { recursive: true })
writeFileSync(path.join(resourceBundle, 'LensSequence', 'README.md'), 'optional frames fixture')
}
const result = runFixtureCommand([
'bash',
'-c',
`
source "$1"
TEST_BUNDLE_DIR="$2"
BUILD_DIR="$TEST_BUNDLE_DIR/build"
BIN_PATH="$TEST_BUNDLE_DIR/fixture-binary"
RESOURCE_BUNDLE_PATH="$TEST_BUNDLE_DIR/cu-helper_cc-haha-computer-use.bundle"
APP_PATH="$TEST_BUNDLE_DIR/cc-haha-computer-use.app"
BUNDLE_ID="dev.cchaha.cu-helper"
SIGN_IDENTITY="fixture-only"
RESOLVED_TIMESTAMP_MODE="none"
if [ "$3" = "missing" ]; then
APP_ICON_PATH="$TEST_BUNDLE_DIR/missing.icns"
fi
codesign() {
case "$1" in
--force) cp -R "$APP_PATH/Contents" "$TEST_BUNDLE_DIR/contents-at-sign" ;;
-dv) printf 'Identifier=%s\\n' "$BUNDLE_ID" ;;
esac
}
wrap_app
`,
'cu-helper-app-icon-test',
buildScript,
directory,
options.missingIcon ? 'missing' : 'present',
], { cwd: directory })
return {
directory,
contents: path.join(directory, 'cc-haha-computer-use.app', 'Contents'),
exitCode: result.exitCode,
stderr: result.stderr.toString(),
}
}
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')
const binary = path.join(app, 'Contents', 'MacOS', 'cc-haha-computer-use')
mkdirSync(path.dirname(binary), { recursive: true })
mkdirSync(path.join(app, 'Contents', 'Resources', resourceBundleName, 'LensSequence'), { recursive: true })
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
[ "$1" = "--probe-cursor-resources" ] || exit 40
case '${mode}' in
crash) exit 41 ;;
invalid-json) printf 'not-json'; exit 0 ;;
build-path) resource_dir="$(cat "$(dirname "$0")/../Resources/outside-resource-path")" ;;
*) resource_dir="$(cd "$(dirname "$0")/../Resources/${resourceBundleName}/LensSequence" && pwd -P)" ;;
esac
printf '{"resourceDirectory":"%s","frameCount":0,"proceduralFallback":true}\\n' "$resource_dir"
`)
chmodSync(binary, 0o755)
const result = runFixtureCommand([
'bash', '-c', `
source "$1"
APP_PATH="$2"
ARCH="$3"
uname() { printf 'arm64\\n'; }
verify_relocated_cursor_resources
`, 'cu-helper-resource-probe-test', buildScript, app,
crossArch ? 'x86_64' : 'arm64',
], {
env: { PATH: process.env.PATH, HOME: directory, TMPDIR: directory },
cwd: directory,
})
return {
exitCode: result.exitCode,
stderr: result.stderr.toString(),
leftovers: readdirSync(directory).filter(name => name.startsWith('cc-haha-cursor-probe.')),
}
}
function resolveTimestampArgument(identity: string, mode = 'auto') {
const result = runFixtureCommand([
'bash',
'-c',
[
'source "$1"',
'security() { return 1; }',
'SIGN_IDENTITY="$2"',
'CU_HELPER_TIMESTAMP_MODE="$3"',
'resolve_timestamp_mode',
'printf "%s" "$CODESIGN_TIMESTAMP_ARG"',
].join('; '),
'cu-helper-build-test',
buildScript,
identity,
mode,
])
if (result.exitCode !== 0) {
throw new Error(result.stderr.toString() || `build.sh probe exited ${result.exitCode}`)
}
return result.stdout.toString()
}
function resolveIdentityWithOnlyDeveloperId() {
const result = runFixtureCommand([
'bash',
'-c',
[
'source "$1"',
'first_apple_development_identity() { return 1; }',
'first_developer_id_application_identity() { printf "%s" "Developer ID Application: Example Corp (TEAM123456)"; }',
'resolve_identity',
'printf "%s" "$SIGN_IDENTITY"',
].join('; '),
'cu-helper-build-test',
buildScript,
])
return {
exitCode: result.exitCode,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
}
}
describe('cu-helper build signing timestamp', () => {
test('uses a secure timestamp for Developer ID distribution signatures', () => {
expect(
resolveTimestampArgument('Developer ID Application: Example Corp (TEAM123456)'),
).toBe('--timestamp')
})
test('keeps local Apple Development builds offline by default', () => {
expect(
resolveTimestampArgument('Apple Development: Developer (TEAM123456)'),
).toBe('--timestamp=none')
})
test('allows CI to require a secure timestamp explicitly', () => {
expect(resolveTimestampArgument('0123456789ABCDEF', 'secure')).toBe('--timestamp')
})
})
describe('cu-helper build signing identity', () => {
test('falls through to Developer ID when no Apple Development identity exists', () => {
const result = resolveIdentityWithOnlyDeveloperId()
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe('Developer ID Application: Example Corp (TEAM123456)')
})
})
describe('cu-helper architecture-specific build output', () => {
test.each(['arm64', 'x86_64'] as const)(
'resolves %s products from the matching SwiftPM bin directory',
(arch) => {
const result = resolveArchitectureSpecificBuildPaths(arch)
expect(result.exitCode).toBe(0)
expect(result.lines).toEqual([
result.binDir,
path.join(result.binDir, 'cc-haha-computer-use'),
path.join(result.binDir, 'cc-haha-computer-use.app'),
path.join(result.binDir, 'cu-helper_cc-haha-computer-use.bundle'),
])
},
)
test('verifies the requested Mach-O architecture before signing', () => {
const source = readFileSync(buildScript, 'utf8')
expect(source).toContain('lipo "$BIN_PATH" -verify_arch "$ARCH"')
expect(source.indexOf('lipo "$BIN_PATH" -verify_arch "$ARCH"'))
.toBeLessThan(source.indexOf('\nsign() {'))
})
})
describe.skipIf(process.platform !== 'darwin')('cu-helper permission-list app icon', () => {
test('declares and bundles the product icon before signing the helper app', () => {
const result = wrapFixtureApp()
expect(result.exitCode).toBe(0)
const plist = runFixtureCommand([
'/usr/bin/plutil', '-convert', 'json', '-o', '-',
path.join(result.contents, 'Info.plist'),
])
expect(plist.exitCode).toBe(0)
const info = JSON.parse(plist.stdout.toString())
expect(info.CFBundleIdentifier).toBe('dev.cchaha.cu-helper')
expect(info.CFBundleExecutable).toBe('cc-haha-computer-use')
expect(info.CFBundleIconFile).toBe('icon.icns')
const expectedIcon = readFileSync(productIcon)
expect(expectedIcon.subarray(0, 4).toString()).toBe('icns')
expect(readFileSync(path.join(result.contents, 'Resources', info.CFBundleIconFile)))
.toEqual(expectedIcon)
expect(readFileSync(path.join(result.directory, 'contents-at-sign', 'Resources', info.CFBundleIconFile)))
.toEqual(expectedIcon)
})
test('refuses to sign an app when the required product icon is missing', () => {
const result = wrapFixtureApp({ missingIcon: true })
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toContain('App icon not found')
expect(existsSync(path.join(result.directory, 'contents-at-sign'))).toBe(false)
})
})
describe('cu-helper packaged cursor resources', () => {
test('copies the complete SwiftPM resource directory into standard Resources before signing', () => {
const result = wrapFixtureApp()
expect(result.exitCode).toBe(0)
const relative = path.join('Resources', resourceBundleName, 'LensSequence', 'README.md')
expect(readFileSync(path.join(result.contents, relative), 'utf8')).toBe('optional frames fixture')
expect(readFileSync(path.join(result.directory, 'contents-at-sign', relative), 'utf8')).toBe('optional frames fixture')
expect(existsSync(path.join(result.contents, 'MacOS', resourceBundleName))).toBe(false)
})
test('refuses to sign when the declared SwiftPM resource bundle was not produced', () => {
const result = wrapFixtureApp({ missingResources: true })
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toContain('Cursor resource bundle not found')
expect(existsSync(path.join(result.directory, 'contents-at-sign'))).toBe(false)
})
})
describe.skipIf(process.platform !== 'darwin')('cu-helper relocated resource probe', () => {
test('loads from the relocated app even when the optional frame directory has no PNGs', () => {
const result = probeFixtureApp('packaged')
expect(result.exitCode).toBe(0)
expect(result.stderr).toContain('verified: relocated cursor resources')
expect(result.leftovers).toEqual([])
})
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([])
})
test('reports cross-architecture execution as skipped without running the probe', () => {
const result = probeFixtureApp('crash', true)
expect(result.exitCode).toBe(0)
expect(result.stderr).toContain('skipped: cursor resource execution probe (target x86_64, host arm64)')
expect(result.stderr).not.toContain('verified: relocated cursor resources')
expect(result.leftovers).toEqual([])
})
})