diff --git a/packages/host-kit/package.json b/packages/host-kit/package.json index 72a6b4f4db..8e1f564f7b 100644 --- a/packages/host-kit/package.json +++ b/packages/host-kit/package.json @@ -20,6 +20,10 @@ "types": "./src/archive.ts", "default": "./src/archive.ts" }, + "./archive-fixtures": { + "types": "./src/archive.fixtures.ts", + "default": "./src/archive.fixtures.ts" + }, "./audio-probe-fixtures": { "types": "./src/audio-probe.fixtures.ts", "default": "./src/audio-probe.fixtures.ts" diff --git a/packages/host-kit/src/internal/archive-extraction.fixtures.ts b/packages/host-kit/src/archive.fixtures.ts similarity index 77% rename from packages/host-kit/src/internal/archive-extraction.fixtures.ts rename to packages/host-kit/src/archive.fixtures.ts index fe56667598..955c446d6f 100644 --- a/packages/host-kit/src/internal/archive-extraction.fixtures.ts +++ b/packages/host-kit/src/archive.fixtures.ts @@ -3,8 +3,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { crc32, gzipSync } from 'node:zlib'; import * as tar from 'tar-stream'; -import { runCmdSync } from './exec.ts'; -import { mkdtempForTest } from './tmp-dir.fixtures.ts'; +import { mkdtempForTest } from './internal/tmp-dir.fixtures.ts'; export async function createArchiveWorkspace(): Promise<{ archivePath: string; @@ -27,21 +26,30 @@ export async function createTruncatedTgz(archivePath: string): Promise { await fs.writeFile(archivePath, gzipSync(headerOnly)); } +/** Two stored entries whose second carries the encrypted general-purpose flag bit. */ export async function createZipWithEncryptedSecondEntry(archivePath: string): Promise { - const staging = path.join(path.dirname(archivePath), 'zip-input'); - await fs.mkdir(staging); - await fs.writeFile(path.join(staging, 'first.txt'), 'first'); - await fs.writeFile(path.join(staging, 'second.txt'), 'second'); - runCmdSync('zip', ['-q', archivePath, 'first.txt'], { cwd: staging }); - runCmdSync('zip', ['-q', '-P', 'secret', archivePath, 'second.txt'], { cwd: staging }); + await writeZipFixture(archivePath, [ + { name: 'first.txt', mode: ZIP_FILE_MODE, data: 'first' }, + { name: 'second.txt', mode: ZIP_FILE_MODE, data: 'second', encrypted: true }, + ]); } +const ZIP_FILE_MODE = 0o100644; + /** One stored zip entry; `mode` is the full Unix `st_mode`, file-type bits included. */ -export type ZipFixtureEntry = { name: string; mode: number; data?: string }; +export type ZipFixtureEntry = { + name: string; + mode: number; + data?: string; + /** Declares the entry encrypted, which no archive reader may then extract. */ + encrypted?: boolean; +}; /** * Writes a stored (uncompressed) zip whose names and Unix attributes are exactly as given, which - * `zip` itself refuses to produce for links, special files, and escaping names. + * `zip` itself refuses to produce for links, special files, and escaping names. An entry declared + * `encrypted` carries the traditional encryption flag with a padded 12-byte encryption header and + * a zero CRC, the shape readers must refuse before extracting anything. */ export async function writeZipFixture( archivePath: string, @@ -52,24 +60,29 @@ export async function writeZipFixture( let offset = 0; for (const entry of entries) { const name = Buffer.from(entry.name, 'utf8'); - const data = Buffer.from(entry.data ?? '', 'utf8'); - const checksum = crc32(data); + const payload = Buffer.from(entry.data ?? '', 'utf8'); + // A traditionally encrypted stored entry carries a 12-byte encryption header before the + // ciphertext, and readers validate that against the declared sizes, so the record declares + // it and the data section pads it; the payload itself never decrypts to anything. + const data = entry.encrypted ? Buffer.concat([Buffer.alloc(12), payload]) : payload; + const checksum = entry.encrypted ? 0 : crc32(payload); + const flags = 0x0800 | (entry.encrypted ? 0x0001 : 0); const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); - local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(flags, 6); local.writeUInt32LE(checksum, 14); local.writeUInt32LE(data.length, 18); - local.writeUInt32LE(data.length, 22); + local.writeUInt32LE(payload.length, 22); local.writeUInt16LE(name.length, 26); const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE((3 << 8) | 20, 4); central.writeUInt16LE(20, 6); - central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(flags, 8); central.writeUInt32LE(checksum, 16); central.writeUInt32LE(data.length, 20); - central.writeUInt32LE(data.length, 24); + central.writeUInt32LE(payload.length, 24); central.writeUInt16LE(name.length, 28); central.writeUInt32LE((entry.mode << 16) >>> 0, 38); central.writeUInt32LE(offset, 42); diff --git a/packages/host-kit/src/internal/archive-extraction-tar.test.ts b/packages/host-kit/src/internal/archive-extraction-tar.test.ts index 81f9d93c79..7cfc61093a 100644 --- a/packages/host-kit/src/internal/archive-extraction-tar.test.ts +++ b/packages/host-kit/src/internal/archive-extraction-tar.test.ts @@ -4,7 +4,7 @@ import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import { extractTarArchive } from './archive-extraction-tar.ts'; import { ArchiveBudget } from './archive-safety.ts'; -import { createArchiveWorkspace, createTruncatedTgz } from './archive-extraction.fixtures.ts'; +import { createArchiveWorkspace, createTruncatedTgz } from '../archive.fixtures.ts'; test('tar inspection rejects declared bytes before draining an over-budget entry', async () => { const workspace = await createArchiveWorkspace(); diff --git a/packages/host-kit/src/internal/archive-extraction-zip.test.ts b/packages/host-kit/src/internal/archive-extraction-zip.test.ts index fea4bcb458..ea1fd4aacb 100644 --- a/packages/host-kit/src/internal/archive-extraction-zip.test.ts +++ b/packages/host-kit/src/internal/archive-extraction-zip.test.ts @@ -4,10 +4,7 @@ import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import { extractZipArchive } from './archive-extraction-zip.ts'; import { ArchiveBudget } from './archive-safety.ts'; -import { - createArchiveWorkspace, - createZipWithEncryptedSecondEntry, -} from './archive-extraction.fixtures.ts'; +import { createArchiveWorkspace, createZipWithEncryptedSecondEntry } from '../archive.fixtures.ts'; test('zip inspection stops at the entry budget before inspecting later entries', async () => { const workspace = await createArchiveWorkspace(); diff --git a/packages/host-kit/src/internal/archive-extraction.test.ts b/packages/host-kit/src/internal/archive-extraction.test.ts index e50336efae..571dcbfa3b 100644 --- a/packages/host-kit/src/internal/archive-extraction.test.ts +++ b/packages/host-kit/src/internal/archive-extraction.test.ts @@ -9,7 +9,7 @@ import { writeZipFixture, type TarFixtureEntry, type ZipFixtureEntry, -} from './archive-extraction.fixtures.ts'; +} from '../archive.fixtures.ts'; import { mkdtempForTest } from './tmp-dir.fixtures.ts'; const S_IFREG = 0o100000; diff --git a/packages/platform-android/src/__tests__/emulator-lifecycle.test.ts b/packages/platform-android/src/__tests__/emulator-lifecycle.test.ts index 8185ed2db5..a9bf41e3a6 100644 --- a/packages/platform-android/src/__tests__/emulator-lifecycle.test.ts +++ b/packages/platform-android/src/__tests__/emulator-lifecycle.test.ts @@ -110,7 +110,7 @@ function bootPropFailure(stdout: string, stderr: string) { }; } -/** A poll that keeps missing like a real booting device does, and actually burns wall-clock time. */ +/** A poll that keeps missing like a real booting device does and burns the given clock time. */ function slowBootPropFailure(delayMs: number) { return async () => { await new Promise((resolve) => setTimeout(resolve, delayMs)); @@ -118,10 +118,14 @@ function slowBootPropFailure(delayMs: number) { }; } -async function expectBootFailure( +/** + * Starts the wait and resolves to the typed failure it must produce. The caller drives any timers + * between starting and awaiting when the test runs on a fake clock. + */ +function startedBootFailure( timeoutMs: number, ): Promise<{ code: string; message: string; details: Record }> { - return await waitForAndroidBoot('emulator-5554', timeoutMs).then( + return waitForAndroidBoot('emulator-5554', timeoutMs).then( () => { throw new Error('expected waitForAndroidBoot to reject'); }, @@ -133,6 +137,12 @@ async function expectBootFailure( ); } +async function expectBootFailure( + timeoutMs: number, +): Promise<{ code: string; message: string; details: Record }> { + return await startedBootFailure(timeoutMs); +} + test('a boot poll against an offline device fails as an adb transport loss without retrying', async () => { mockRunCmd.mockImplementation(bootPropFailure('', 'error: device offline')); @@ -149,21 +159,28 @@ test('a boot poll against an offline device fails as an adb transport loss witho }); test('a still-booting device past its deadline reports an Android boot timeout', async () => { - // 80 ms per poll with a 50 ms retry cadence crosses the 150 ms deadline inside the second - // poll, so the failure is a device that ran out of budget, not three fast poll misses. - mockRunCmd.mockImplementation(slowBootPropFailure(80)); - - const failure = await expectBootFailure(150); - - assert.equal(failure.code, 'COMMAND_FAILED'); - assert.equal(failure.message, 'Android device did not finish booting in time'); - assert.equal(failure.details.reason, 'ANDROID_BOOT_TIMEOUT'); - assert.equal(failure.details.timeoutMs, 150); - assert.equal(Number(failure.details.elapsedMs) >= 150, true); - assert.equal(mockRunCmd.mock.calls.length, 2); - // The classifier sees BOOT_COMMAND_FAILED from the still-booting throw; the caller must - // normalize it to the timeout, not trust the raw classification. - assert.notEqual(failure.details.reason, 'BOOT_COMMAND_FAILED'); + // On a fake clock: each poll burns 80 ms, and the 150 ms deadline is crossed inside the + // second poll, so the failure is a device that ran out of budget, not a run of fast poll + // misses. Fake timers keep the 80 ms/150 ms geometry exact instead of racing the host. + vi.useFakeTimers(); + try { + mockRunCmd.mockImplementation(slowBootPropFailure(80)); + + const started = startedBootFailure(150); + await vi.runAllTimersAsync(); + const failure = await started; + + assert.equal(failure.code, 'COMMAND_FAILED'); + assert.equal(failure.message, 'Android device did not finish booting in time'); + // The classifier sees BOOT_COMMAND_FAILED from the still-booting throw; the caller must + // normalize it to the timeout rather than trust the raw classification. + assert.equal(failure.details.reason, 'ANDROID_BOOT_TIMEOUT'); + assert.equal(failure.details.timeoutMs, 150); + assert.equal(Number(failure.details.elapsedMs) >= 150, true); + assert.equal(mockRunCmd.mock.calls.length, 2); + } finally { + vi.useRealTimers(); + } }); test('a TOOL_MISSING adb inside the boot loop keeps its error code and hint', async () => { diff --git a/packages/platform-apple/src/core/__tests__/install-artifact.test.ts b/packages/platform-apple/src/core/__tests__/install-artifact.test.ts index 8d97edbe5a..cda3ca4d84 100644 --- a/packages/platform-apple/src/core/__tests__/install-artifact.test.ts +++ b/packages/platform-apple/src/core/__tests__/install-artifact.test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { runCmdSync } from '@agent-device/host-kit/command'; +import { writeZipFixture } from '@agent-device/host-kit/archive-fixtures'; import { AppError } from '@agent-device/kernel/errors'; import { createLocalAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts'; import { prepareIosInstallArtifact } from '../install-artifact.ts'; @@ -99,9 +99,13 @@ test('prepareIosInstallArtifact keeps a non-dotted hint a name-only match', asyn type PayloadBundleFixture = { name: string; bundleId?: string }; +const ZIP_DIR_MODE = 0o040000 | 0o755; +const ZIP_FILE_MODE = 0o100644; + /** * Builds a real zip-format IPA carrying the given `Payload/*.app` bundles and runs `run` against - * it. Info.plist reads go through a fake tool provider keyed by the bundle's own directory, so + * it, written through host-kit's own zip fixture so the test needs no `zip` binary on the host. + * Info.plist reads go through a fake tool provider keyed by the bundle's own directory, so * each payload reports an identity of its own without plist tooling on the host. */ async function withMultiPayloadIpa( @@ -109,17 +113,15 @@ async function withMultiPayloadIpa( run: (ipaPath: string) => Promise, ): Promise { const root = await mkdtempForTest('agent-device-ios-multi-payload-'); - const staging = path.join(root, 'staging'); - for (const bundle of bundles) { - const appDir = path.join(staging, 'Payload', `${bundle.name}.app`); - await fs.mkdir(appDir, { recursive: true }); - await fs.writeFile( - path.join(appDir, 'Info.plist'), - '', - ); - } const ipaPath = path.join(root, 'multi.ipa'); - runCmdSync('zip', ['-qr', ipaPath, '.'], { cwd: staging }); + await writeZipFixture(ipaPath, [ + { name: 'Payload/', mode: ZIP_DIR_MODE }, + ...bundles.map(({ name }) => ({ + name: `Payload/${name}.app/Info.plist`, + mode: ZIP_FILE_MODE, + data: '', + })), + ]); const provider = createLocalAppleToolProvider({ plist: { diff --git a/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts b/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts index 31e15463c0..b06dabb3f2 100644 --- a/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts +++ b/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts @@ -1,5 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; +import { AppError } from '@agent-device/kernel/errors'; import { copyCoreDeviceRunnerFile } from '../physical-device-files.ts'; import { IOS_DEVICE } from '../../__tests__/device-fixtures.ts'; import { withFakeAppleTool, type FakeAppleToolScript } from '../../__tests__/fake-apple-tool.ts'; @@ -83,7 +84,16 @@ test('copyCoreDeviceRunnerFile refuses to start once the deadline is spent', asy async () => await copyCoreDeviceRunnerFile(IOS_DEVICE, '/tmp/shot.png', '/out/shot.png', 0), { device: IOS_DEVICE }, ), - /iOS runner file copy timed out after 0ms/, + (error: unknown) => { + // The message alone says only "timed out"; the typed step is what tells the caller the + // spent deadline was the file copy's, not another phase of the physical-device flow. + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.match(error.message, /iOS runner file copy timed out after 0ms/); + assert.equal(error.details?.step, 'runner file copy'); + assert.equal(error.details?.timeoutMs, 0); + return true; + }, ); assert.deepEqual(calls, []); }); diff --git a/packages/platform-apple/src/os/macos/host-provider.test.ts b/packages/platform-apple/src/os/macos/host-provider.test.ts index 176600c6c5..23a4eca08b 100644 --- a/packages/platform-apple/src/os/macos/host-provider.test.ts +++ b/packages/platform-apple/src/os/macos/host-provider.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import fs from 'node:fs'; +import { promises as fs } from 'node:fs'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; @@ -7,14 +7,15 @@ import type { AppleToolCommandExecutor } from '../../core/tool-provider-types.ts import type { ExecResult } from '@agent-device/host-kit/command'; // App discovery stats each candidate root through the host-file seam and asks that seam for the -// home directory whose `Applications` folder is the third root. Those two are the only effects -// mocked here: which roots exist is a host fact a Linux coverage runner cannot answer the way -// macOS does, so the test declares them. Every `find` and every Info.plist read is driven -// through the executor and plist-reader arguments the provider factory itself takes. +// home directory whose `Applications` folder is the third root. Only the two system roots are +// declared here: which they are (a directory, a plain file, or absent) is a macOS host fact a +// Linux coverage runner cannot answer the way macOS does, and each declaration is answered with +// the real `fs.Stats` of a backing tmp path. The user root and the home itself exist for real in +// tmp dirs the test creates; nothing is hand-statted. Every `find` and every Info.plist read is +// driven through the executor and plist-reader arguments the provider factory itself takes. const hostFileState = vi.hoisted(() => ({ home: '', - directories: new Set(), - files: new Set(), + systemRoots: new Map(), })); vi.mock('@agent-device/host-kit/host-file', async (importOriginal) => { @@ -23,17 +24,8 @@ vi.mock('@agent-device/host-kit/host-file', async (importOriginal) => { ...actual, hostHomeDirectory: () => hostFileState.home, hostFileStat: async (filePath: string) => { - if (hostFileState.directories.has(filePath)) { - return { isDirectory: () => true } as fs.Stats; - } - if (hostFileState.files.has(filePath)) { - return { isDirectory: () => false } as fs.Stats; - } - const error = new Error( - `ENOENT: no such file or directory, stat '${filePath}'`, - ) as NodeJS.ErrnoException; - error.code = 'ENOENT'; - throw error; + const backingPath = hostFileState.systemRoots.get(filePath); + return await actual.hostFileStat(backingPath ?? filePath); }, }; }); @@ -56,8 +48,9 @@ function bundleName(plistPath: string): string { } /** - * A scratch home for one run of `listApps`, so the user root is a path the test controls. - * Nothing is written under it; the `hostFileStat` mock above answers its existence. + * A scratch home for one run of `listApps`, so the user root is a path the test controls. The + * home itself really exists in tmp; whether its `Applications` directory exists is each test's + * choice, made by actually creating it or not. */ async function scratchHome(): Promise { const home = await mkdtempForTest('macos-host-home-'); @@ -65,6 +58,30 @@ async function scratchHome(): Promise { return home; } +/** + * Declares one system root as a host fact, answered through the real `fs.Stats`: a backing tmp + * directory (a directory root), a backing tmp file (a root that exists but is not a directory), + * or a path inside a tmp dir that is never created (a root that does not exist at all, so the + * stat rejects with ENOENT the way the kernel reports a missing path). + */ +async function declareSystemRoot( + root: string, + kind: 'directory' | 'file' | 'absent', +): Promise { + const backing = await mkdtempForTest('macos-host-root-'); + if (kind === 'directory') { + hostFileState.systemRoots.set(root, backing); + return; + } + if (kind === 'file') { + const backingFile = path.join(backing, 'not-a-directory'); + await fs.writeFile(backingFile, ''); + hostFileState.systemRoots.set(root, backingFile); + return; + } + hostFileState.systemRoots.set(root, path.join(backing, 'never-created')); +} + function recordingRunCommand(params: { findLinesByRoot?: Record; failingRoots?: readonly string[]; @@ -105,16 +122,15 @@ async function listApps(params: { afterEach(() => { findCalls = []; hostFileState.home = ''; - hostFileState.directories.clear(); - hostFileState.files.clear(); + hostFileState.systemRoots.clear(); }); test('listApps walks every present root, normalizes find output, and sorts resolved apps by name', async () => { const home = await scratchHome(); const userApplications = path.join(home, 'Applications'); - hostFileState.directories.add('/Applications'); - hostFileState.directories.add('/System/Applications'); - hostFileState.directories.add(userApplications); + await fs.mkdir(userApplications); + await declareSystemRoot('/Applications', 'directory'); + await declareSystemRoot('/System/Applications', 'directory'); const found = await listApps({ filter: 'all', @@ -157,9 +173,9 @@ test('listApps walks every present root, normalizes find output, and sorts resol test('listApps probes each root independently: a non-directory root is never searched and a failing find contributes nothing', async () => { const home = await scratchHome(); const userApplications = path.join(home, 'Applications'); - hostFileState.files.add('/Applications'); - hostFileState.directories.add('/System/Applications'); - hostFileState.directories.add(userApplications); + await fs.mkdir(userApplications); + await declareSystemRoot('/Applications', 'file'); + await declareSystemRoot('/System/Applications', 'directory'); const found = await listApps({ filter: 'all', @@ -184,6 +200,10 @@ test('listApps probes each root independently: a non-directory root is never sea test('listApps finds nothing when no candidate root exists and reads no plist for it', async () => { await scratchHome(); + // Every candidate root is declared absent, including the system roots a real macOS host + // carries; the user root is absent because its scratch home never grows an Applications dir. + await declareSystemRoot('/Applications', 'absent'); + await declareSystemRoot('/System/Applications', 'absent'); const found = await listApps({ filter: 'all', @@ -199,7 +219,8 @@ test('listApps finds nothing when no candidate root exists and reads no plist fo test('listApps keeps only bundles whose plist carries an identifier and fails open on a plist read that throws', async () => { await scratchHome(); - hostFileState.directories.add('/Applications'); + await declareSystemRoot('/Applications', 'directory'); + await declareSystemRoot('/System/Applications', 'absent'); const read: string[] = []; const found = await listApps({ @@ -246,7 +267,8 @@ test('listApps keeps only bundles whose plist carries an identifier and fails op test('listApps hides Apple bundles for the user-installed filter and shows them for all', async () => { await scratchHome(); - hostFileState.directories.add('/Applications'); + await declareSystemRoot('/Applications', 'directory'); + await declareSystemRoot('/System/Applications', 'absent'); const findLinesByRoot = { '/Applications': ['/Applications/MyTool.app', '/Applications/System Preferences.app'], }; diff --git a/packages/platform-apple/src/runner/__tests__/runner-xctestrun-products.test.ts b/packages/platform-apple/src/runner/__tests__/runner-xctestrun-products.test.ts index b6a51d7b57..826009c3fd 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-xctestrun-products.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-xctestrun-products.test.ts @@ -101,6 +101,12 @@ test('product-reference values of the wrong shape never resolve, and a configure root, path.join('Build', 'Products', 'Debug-iphonesimulator', 'Runner-Runner.app'), ); + // The surviving configured target names a product no other entry in this xctestrun names, so + // its contribution is observable: dropping that target from the collection changes the result. + const helperBundlePath = seedProductBundle( + root, + path.join('Build', 'Products', 'Debug-iphonesimulator', 'Helper.app'), + ); serveParsedPlist({ // A legacy single-target map beside the malformed configuration list. @@ -117,26 +123,27 @@ test('product-reference values of the wrong shape never resolve, and a configure TestTargets: { TestBundlePath: '__TESTROOT__/Missing.app' }, }, { - // Non-record members of an array target list are filtered out. + // Non-record members of an array target list are filtered out; had this list been + // collected unfiltered, the null member would make the reference scan throw instead of + // contributing nothing. TestTargets: [null, 42, '__TESTROOT__/Also-Missing.app'], }, { + // The one record target: it carries no TestBundlePath and no name of its own, so only + // being a record with a usable reference key is what makes its product resolve. TestTargets: [ { - DependentProductPaths: [ - '__TESTROOT__/Debug-iphonesimulator/Runner-Runner.app', - 42, - null, - ], + DependentProductPaths: ['__TESTROOT__/Debug-iphonesimulator/Helper.app', 42, null], }, ], }, ], }); - // The legacy target's string TestBundlePath and the one well-formed dependent product both - // resolve to the seeded bundle; nothing the malformed shapes name can appear. - assert.deepEqual(await resolveExistingXctestrunProductPaths(xctestrunPath), [appBundlePath]); + // The legacy target's string TestBundlePath and the one well-formed dependent product each + // resolve to their own seeded bundle; nothing the malformed shapes name can appear. + const resolved = await resolveExistingXctestrunProductPaths(xctestrunPath); + assert.deepEqual([...resolved!].sort(), [appBundlePath, helperBundlePath].sort()); }); test('an xctestrun whose parsed form names no product resolves to nothing', async () => { diff --git a/packages/platform-linux/src/__tests__/snapshot.test.ts b/packages/platform-linux/src/__tests__/snapshot.test.ts index 9c1d22b5b3..88b401b7a9 100644 --- a/packages/platform-linux/src/__tests__/snapshot.test.ts +++ b/packages/platform-linux/src/__tests__/snapshot.test.ts @@ -125,7 +125,9 @@ test('readLinuxTextAtPoint returns an empty string when no rect covers the point }); test('readLinuxTextAtPoint ties depth 0 through rect area when rects are degenerate', async () => { - // Zero-area rects keep the sort stable on rect area (0 * 0 === 0 * 0) and still resolve by tree order. + // A zero-width or zero-height rect still has area 0 (0 * 10 === 0 * 0), so the pair ties on + // area and resolves by tree order; and neither rect covers a point one pixel out, so the + // inclusive-boundary match, not a size fudge, decides whether anything reads at all. captureNodes([ node({ index: 0, depth: 0, label: 'first', rect: { x: 0, y: 0, width: 0, height: 0 } }), node({ index: 1, depth: 0, label: 'second', rect: { x: 0, y: 0, width: 0, height: 10 } }), @@ -133,12 +135,27 @@ test('readLinuxTextAtPoint ties depth 0 through rect area when rects are degener // Vitest/JS sort is stable, so equal depth and equal area resolve in tree order. assert.equal(await readLinuxTextAtPoint(0, 0, 'desktop'), 'first'); + assert.equal(await readLinuxTextAtPoint(1, 1, 'desktop'), ''); }); -test('readLinuxTextAtPoint defaults a missing depth to 0 when ordering matches', async () => { +test('readLinuxTextAtPoint ranks a missing depth as depth 0, in both sort directions', async () => { + // Two fixtures pin the default to exactly 0. Here `no-depth` ties the depth-0 nodes and loses + // only on area, so any fallback above 0 (or 1) ranks it deeper than them and it — not + // `smaller` — would come back from the read. captureNodes([ node({ index: 0, label: 'no-depth', rect: { x: 0, y: 0, width: 100, height: 100 } }), node({ index: 1, depth: 0, label: 'root', rect: { x: 0, y: 0, width: 100, height: 100 } }), + node({ index: 2, depth: 0, label: 'smaller', rect: { x: 0, y: 0, width: 60, height: 60 } }), + ]); + + assert.equal(await readLinuxTextAtPoint(50, 50, 'desktop'), 'smaller'); + + // The complement catches a negative fallback: the missing-depth node carries the smaller + // area, so depth 0 ranks it first among the depth-0 matches, while any fallback below 0 + // ranks it behind `root` and the read would return `root`. + captureNodes([ + node({ index: 0, depth: 0, label: 'root', rect: { x: 0, y: 0, width: 100, height: 100 } }), + node({ index: 1, label: 'no-depth', rect: { x: 0, y: 0, width: 60, height: 60 } }), ]); assert.equal(await readLinuxTextAtPoint(50, 50, 'desktop'), 'no-depth'); diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 838d35a64e..ec654b60f3 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -552,6 +552,8 @@ test('the real tree parses, declares, and passes R11', () => { ); assert.deepEqual([...hostKitPackage.exportTargets.keys()].sort(), [ '@agent-device/host-kit/archive', + // Test-only entry: the zip/tar archive fixture writers shared with platform tests (#3461). + '@agent-device/host-kit/archive-fixtures', // Test-only entry: the one inert audio-probe double the platform runtime fixtures share. '@agent-device/host-kit/audio-probe-fixtures', '@agent-device/host-kit/code-signature',