11import fs from 'node:fs' ;
22import path from 'node:path' ;
3- import { onTestFinished , test } from 'vitest' ;
3+ import { expect , onTestFinished , test } from 'vitest' ;
44import assert from 'node:assert/strict' ;
5+ import { AppError } from '@agent-device/kernel/errors' ;
56import { IOS_DEVICE , IOS_SIMULATOR , MACOS_DEVICE } from './device-fixtures.ts' ;
67import {
8+ diffComparableRunnerCacheMetadata ,
79 resolveRunnerBundleBuildSettings ,
810 resolveRunnerMaxConcurrentDestinationsFlag ,
911 resolveRunnerSigningBuildSettings ,
@@ -12,6 +14,9 @@ import {
1214 resolveExpectedRunnerCacheMetadata ,
1315} from '../runner-cache-metadata.ts' ;
1416import { mkdtempForTestSync } from './tmp-dir.ts' ;
17+ import { appleToolchainProbeResult , stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts' ;
18+
19+ const runCmdSync = stubAppleToolchainProbes ( ) ;
1520
1621test ( 'resolveRunnerMaxConcurrentDestinationsFlag uses simulator flag for simulators' , ( ) => {
1722 assert . equal (
@@ -233,3 +238,156 @@ test('runner cache metadata ignores development-only SwiftPM trees but keeps run
233238 ) . runnerSourceFingerprint ;
234239 assert . notEqual ( afterRunnerTestChange , afterIgnoredChanges ) ;
235240} ) ;
241+
242+ test ( 'metadata diff names only the comparable keys that differ, with expected and actual' , ( ) => {
243+ const expected = resolveExpectedRunnerCacheMetadata ( IOS_SIMULATOR ) ;
244+ const actual = {
245+ ...expected ,
246+ packageVersion : `${ expected . packageVersion } -next` ,
247+ xcodeBuildVersion : '17A100' ,
248+ runnerPerformanceBuildSettings : [ 'ENABLE_CODE_COVERAGE=YES' ] ,
249+ artifacts : {
250+ xctestrunPath : '/tmp/derived/Runner.xctestrun' ,
251+ xctestrunMtimeMs : 1 ,
252+ xctestrunSize : 2 ,
253+ productPaths : [ { path : '/tmp/derived/Runner.app' , mtimeMs : 1 , size : 2 } ] ,
254+ } ,
255+ } ;
256+
257+ assert . deepEqual ( diffComparableRunnerCacheMetadata ( expected , actual ) , [
258+ {
259+ key : 'runnerPerformanceBuildSettings' ,
260+ expected : JSON . stringify ( expected . runnerPerformanceBuildSettings ) ,
261+ actual : '["ENABLE_CODE_COVERAGE=YES"]' ,
262+ } ,
263+ { key : 'xcodeBuildVersion' , expected : '"17C52"' , actual : '"17A100"' } ,
264+ ] ) ;
265+ } ) ;
266+
267+ test ( 'metadata diff reports a key only one side carries as absent' , ( ) => {
268+ const expected = resolveExpectedRunnerCacheMetadata ( IOS_SIMULATOR ) ;
269+ const { sdkBuildVersion : _sdkBuildVersion , ...withoutSdkBuildVersion } = expected ;
270+
271+ assert . deepEqual (
272+ diffComparableRunnerCacheMetadata ( expected , withoutSdkBuildVersion as typeof expected ) ,
273+ [ { key : 'sdkBuildVersion' , expected : '"23C53"' , actual : '(absent)' } ] ,
274+ ) ;
275+ } ) ;
276+
277+ test ( 'metadata diff is empty for identical metadata' , ( ) => {
278+ const expected = resolveExpectedRunnerCacheMetadata ( IOS_SIMULATOR ) ;
279+
280+ assert . deepEqual ( diffComparableRunnerCacheMetadata ( expected , { ...expected } ) , [ ] ) ;
281+ } ) ;
282+
283+ test ( 'metadata diff elides an over-long value in the middle so both ends stay comparable' , ( ) => {
284+ const expected = resolveExpectedRunnerCacheMetadata ( IOS_SIMULATOR ) ;
285+ const longSetting = ( suffix : string ) => [ `${ 'A' . repeat ( 400 ) } =${ suffix } ` ] ;
286+
287+ const [ difference ] = diffComparableRunnerCacheMetadata (
288+ { ...expected , runnerBundleBuildSettings : longSetting ( 'one' ) } ,
289+ { ...expected , runnerBundleBuildSettings : longSetting ( 'two' ) } ,
290+ ) ;
291+
292+ assert . equal ( difference ?. key , 'runnerBundleBuildSettings' ) ;
293+ assert . ok ( ( difference ?. expected . length ?? 0 ) <= 300 ) ;
294+ assert . ok ( difference ?. expected . startsWith ( '["AAA' ) ) ;
295+ assert . ok ( difference ?. expected . endsWith ( '=one"]' ) ) ;
296+ assert . ok ( difference ?. actual . endsWith ( '=two"]' ) ) ;
297+ } ) ;
298+
299+ function unavailableProbes ( ) : { probe : string ; reason : string } [ ] {
300+ try {
301+ resolveExpectedRunnerCacheMetadata ( IOS_SIMULATOR ) ;
302+ return [ ] ;
303+ } catch ( error ) {
304+ assert . ok ( error instanceof AppError ) ;
305+ assert . equal ( error . details ?. reason , 'apple_toolchain_probe_unavailable' ) ;
306+ const probes = error . details ?. probes as { probe : string ; reason : string } [ ] ;
307+ return probes . map ( ( { probe, reason } ) => ( { probe, reason } ) ) ;
308+ }
309+ }
310+
311+ test ( 'a timed-out probe leaves the toolchain unavailable instead of a comparable value' , ( ) => {
312+ runCmdSync . mockImplementation ( ( command : string , args : readonly string [ ] ) => {
313+ if ( command === 'xcodebuild' ) {
314+ throw new AppError ( 'COMMAND_FAILED' , 'xcodebuild timed out after 5000ms' , {
315+ timeoutMs : 5_000 ,
316+ } ) ;
317+ }
318+ return appleToolchainProbeResult ( command , args ) ;
319+ } ) ;
320+
321+ assert . deepEqual ( unavailableProbes ( ) , [ { probe : 'xcodebuild -version' , reason : 'probe_error' } ] ) ;
322+ } ) ;
323+
324+ test ( 'a failing probe reports its exit status rather than a fabricated SDK version' , ( ) => {
325+ runCmdSync . mockImplementation ( ( command : string , args : readonly string [ ] ) =>
326+ command === 'xcrun'
327+ ? { exitCode : 70 , stdout : '' , stderr : 'xcrun: error: SDK cannot be located\n' }
328+ : appleToolchainProbeResult ( command , args ) ,
329+ ) ;
330+
331+ assert . deepEqual ( unavailableProbes ( ) , [
332+ { probe : 'xcrun --sdk iphonesimulator --show-sdk-version' , reason : 'nonzero_exit' } ,
333+ { probe : 'xcrun --sdk iphonesimulator --show-sdk-build-version' , reason : 'nonzero_exit' } ,
334+ ] ) ;
335+ } ) ;
336+
337+ test ( 'unrecognized xcodebuild output is unavailable, not a partially parsed fingerprint' , ( ) => {
338+ runCmdSync . mockImplementation ( ( command : string , args : readonly string [ ] ) =>
339+ command === 'xcodebuild'
340+ ? { exitCode : 0 , stdout : 'xcode-select: error: tool not configured\n' , stderr : '' }
341+ : appleToolchainProbeResult ( command , args ) ,
342+ ) ;
343+
344+ assert . deepEqual ( unavailableProbes ( ) , [
345+ { probe : 'xcodebuild -version' , reason : 'unparsable_output' } ,
346+ ] ) ;
347+ } ) ;
348+
349+ test ( 'an empty probe answer is unavailable rather than an empty cache key field' , ( ) => {
350+ runCmdSync . mockImplementation ( ( command : string , args : readonly string [ ] ) =>
351+ command === 'xcrun' && args . includes ( '--show-sdk-build-version' )
352+ ? { exitCode : 0 , stdout : '\n' , stderr : '' }
353+ : appleToolchainProbeResult ( command , args ) ,
354+ ) ;
355+
356+ assert . deepEqual ( unavailableProbes ( ) , [
357+ { probe : 'xcrun --sdk iphonesimulator --show-sdk-build-version' , reason : 'empty_output' } ,
358+ ] ) ;
359+ } ) ;
360+
361+ test ( 'an unavailable toolchain fails the cache decision with a retriable typed error' , ( ) => {
362+ runCmdSync . mockImplementation ( ( ) => {
363+ throw new AppError ( 'COMMAND_FAILED' , 'xcodebuild timed out after 5000ms' , { } ) ;
364+ } ) ;
365+
366+ try {
367+ resolveExpectedRunnerCacheMetadata ( IOS_SIMULATOR ) ;
368+ assert . fail ( 'expected an unavailable toolchain to fail the cache decision' ) ;
369+ } catch ( error ) {
370+ assert . ok ( error instanceof AppError ) ;
371+ assert . equal ( error . code , 'COMMAND_FAILED' ) ;
372+ assert . equal ( error . details ?. retriable , true ) ;
373+ expect ( error . message ) . toContain ( 'xcodebuild -version' ) ;
374+ expect ( String ( error . details ?. hint ) ) . toContain ( 'xcode-select' ) ;
375+ }
376+ } ) ;
377+
378+ test ( 'an unavailable probe never reaches cache metadata, and is not memoized as one' , ( ) => {
379+ runCmdSync . mockImplementation ( ( ) => {
380+ throw new AppError ( 'COMMAND_FAILED' , 'xcodebuild timed out after 5000ms' , { } ) ;
381+ } ) ;
382+ expect ( ( ) => resolveExpectedRunnerCacheMetadata ( IOS_SIMULATOR ) ) . toThrow (
383+ / C o u l d n o t r e a d t h e X c o d e t o o l c h a i n v e r s i o n s / ,
384+ ) ;
385+
386+ runCmdSync . mockImplementation ( appleToolchainProbeResult ) ;
387+ const metadata = resolveExpectedRunnerCacheMetadata ( IOS_SIMULATOR ) ;
388+
389+ assert . equal ( metadata . xcodeVersion , '26.2' ) ;
390+ assert . equal ( metadata . xcodeBuildVersion , '17C52' ) ;
391+ assert . equal ( metadata . sdkVersion , '26.2' ) ;
392+ assert . equal ( metadata . sdkBuildVersion , '23C53' ) ;
393+ } ) ;
0 commit comments