2026-10-04 16:02:36 +08:00
import { execFile , spawn } from "node:child_process" ;
2026-10-09 06:48:36 +08:00
import assert from "node:assert/strict" ;
2026-10-04 16:02:36 +08:00
import { mkdtemp , mkdir , writeFile , readFile , rm } from "node:fs/promises" ;
import { tmpdir } from "node:os" ;
import { join } from "node:path" ;
import { fileURLToPath } from "node:url" ;
import { promisify } from "node:util" ;
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js" ;
import { observeNativeUi } from "../dist/native/NativeUiObservation.js" ;
2026-10-07 23:44:41 +08:00
import { NATIVE_UI_HELPER_MAX_BUFFER } from "../dist/native/NativeUiOutputBudget.js" ;
2026-10-09 06:48:36 +08:00
import { artifactMcpResult , withArtifactMcp } from "./lib/artifact-e2e.mjs" ;
import { observeProcessStartIdentity } from "../dist/process/ProcessOwnershipObservation.js" ;
2026-10-04 16:02:36 +08:00
if ( process . platform !== "darwin" )
throw new Error (
"Native UI verification requires macOS, an interactive desktop and Xcode command-line tools" ,
);
const root = await mkdtemp ( join ( tmpdir (), "rea-ui-fixture-" ));
let child ;
2026-10-09 06:48:36 +08:00
let childExit ;
2026-10-04 16:02:36 +08:00
let fixturePid ;
2026-10-09 06:48:36 +08:00
let fixtureIdentity ;
2026-10-09 03:30:07 +08:00
let report ;
2026-10-04 16:02:36 +08:00
try {
2026-10-07 19:52:16 +08:00
const childRetrievalTest = join ( root , "child-retrieval-test" );
await promisify ( execFile )( "/usr/bin/xcrun" , [
"swiftc" ,
fileURLToPath (
new URL ( "../bridge/native/NativeUIChildren.swift" , import . meta . url ),
),
2026-10-24 04:16:35 +08:00
fileURLToPath (
new URL ( "../bridge/native/NativeUIValues.swift" , import . meta . url ),
),
2026-10-07 19:52:16 +08:00
fileURLToPath (
new URL (
"../tests/conformance/native/native-ui-children/main.swift" ,
import . meta . url ,
),
),
"-o" ,
childRetrievalTest ,
]);
2026-10-07 23:44:41 +08:00
const childRetrieval = await promisify ( execFile )( childRetrievalTest , []);
const [ slashRichProducerJson , numericProducerJson ] = childRetrieval . stdout
. trimEnd ()
. split ( "\n" );
if (
slashRichProducerJson === undefined ||
numericProducerJson === undefined
) {
throw new Error (
"Native UI serializer seam did not emit its producer bytes" ,
);
}
const slashRichConsumerJson = JSON . stringify (
JSON . parse ( slashRichProducerJson ),
);
if ( slashRichProducerJson !== slashRichConsumerJson ) {
throw new Error (
"Swift helper JSON bytes differ from the normalized JavaScript consumer representation" ,
);
}
const numericConsumerJson = JSON . stringify ( JSON . parse ( numericProducerJson ));
if (
Buffer . byteLength ( numericProducerJson ) <=
Buffer . byteLength ( numericConsumerJson )
) {
throw new Error (
"Foundation numeric fixture no longer exercises larger producer JSON bytes" ,
);
}
if ( Buffer . byteLength ( numericProducerJson ) > NATIVE_UI_HELPER_MAX_BUFFER ) {
throw new Error ( "Foundation numeric fixture exceeds the raw helper budget" );
}
2026-10-07 19:52:16 +08:00
2026-10-04 16:02:36 +08:00
const contents = join ( root , "Fixture.app" , "Contents" );
await mkdir ( join ( contents , "MacOS" ), { recursive : true });
await writeFile (
join ( contents , "Info.plist" ),
`<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleExecutable</key><string>fixture</string><key>CFBundleIdentifier</key><string>io.rea.source-owned-ui-fixture</string><key>CFBundleName</key><string>REA UI Fixture</string><key>CFBundlePackageType</key><string>APPL</string></dict></plist>` ,
);
const executable = join ( contents , "MacOS" , "fixture" );
await promisify ( execFile )( "/usr/bin/xcrun" , [
"swiftc" ,
"-module-cache-path" ,
join ( root , "modules" ),
fileURLToPath (
new URL ( "../tests/conformance/native/ui.swift" , import . meta . url ),
),
"-o" ,
executable ,
]);
const coordinates = join ( root , "scope.json" );
await writeFile ( coordinates , "" );
child = spawn (
"/usr/bin/open" ,
[ "-W" , "-n" , "-g" , "--stdout" , coordinates , join ( root , "Fixture.app" )],
{ stdio : "ignore" },
);
2026-10-09 06:48:36 +08:00
childExit = new Promise (( resolve ) => child . once ( "exit" , resolve ));
2026-10-04 16:02:36 +08:00
let scope ;
const deadline = Date . now () + 20000 ;
while ( Date . now () < deadline ) {
const output = await readFile ( coordinates , "utf8" );
if ( output . includes ( "\n" )) {
scope = JSON . parse ( output . split ( "\n" )[ 0 ]);
break ;
}
await new Promise (( resolve ) => setTimeout ( resolve , 100 ));
}
if ( scope === undefined )
throw new Error (
"Source-owned UI fixture failed to expose one window within 20 seconds" ,
);
fixturePid = scope . pid ;
2026-10-09 06:48:36 +08:00
fixtureIdentity = await observeProcessStartIdentity ( fixturePid );
assert . equal (
fixtureIdentity ? . state ,
"readable" ,
"Fixture OS start identity is unavailable" ,
);
2026-10-04 16:02:36 +08:00
const target = await parseBinaryTarget ( executable );
if ( ! target . ok ) throw target . error ;
2026-10-09 20:02:52 +08:00
const observation = await observeNativeUi (
target . value ,
"observe_native_ui" ,
{
... scope ,
},
{ environment : process . env },
);
2026-10-04 16:02:36 +08:00
if (
! observation . ok &&
! /accessibility-denied|screen-recording-denied/u . test (
observation . error . message ,
)
)
throw observation . error ;
if ( ! observation . ok && ! process . argv . includes ( "--allow-permission-denial" ))
throw new Error (
`Native UI E2E requires successful capture; permissions prevented coverage: ${ observation . error . message } . Use --allow-permission-denial only for the separate permission diagnostic lane.` ,
);
let scenarioStatus = "not-run-permission-denied" ;
if ( observation . ok ) {
const button = observation . value . initial . nodes . find (
( node ) => node . title === "Increment REA fixture" ,
);
if ( button === undefined )
throw new Error ( "Source-owned fixture button is absent from AX capture" );
const scenario = await observeNativeUi (
target . value ,
"capture_native_ui_scenario" ,
{
... scope ,
screenshot : false ,
steps : [
{ kind : "click" , path : button . path },
{ kind : "wait" , milliseconds : 200 },
],
},
2026-10-09 20:02:52 +08:00
{ environment : process . env },
2026-10-04 16:02:36 +08:00
);
if ( ! scenario . ok ) throw scenario . error ;
if (
scenario . value . steps . some (( step ) => step . outcome !== "completed" ) ||
! scenario . value . steps
. at ( - 1 )
? . after ? . nodes . some (( node ) => node . title === "REA fixture incremented" )
)
throw new Error (
`Source-owned AX scenario failed: ${ JSON . stringify ( scenario . value . steps . map (({ outcome , reason } ) => ({ outcome, reason })))}` ,
);
scenarioStatus = "selected-AX-button-action-observed" ;
const cli = await promisify ( execFile )(
process . execPath ,
[
fileURLToPath ( new URL ( "./rea.mjs" , import . meta . url )),
"capture-native-ui-scenario" ,
executable ,
"--pid" ,
String ( scope . pid ),
"--window-id" ,
String ( scope . window_id ),
"--steps" ,
JSON . stringify ([
{ kind : "click" , path : button . path },
{ kind : "wait" , milliseconds : 200 },
]),
"--json" ,
],
{ maxBuffer : 32 * 1024 * 1024 , timeout : 180000 },
);
const evidence = JSON . parse ( cli . stdout );
if (
evidence . normalized_result ? . steps ? . some (
( step ) => step . outcome !== "completed" ,
) ||
evidence . normalized_result ? . steps ? . length !== 2
)
throw new Error (
2026-10-05 20:16:44 +08:00
`Native UI CLI did not preserve the selected scenario: ${ JSON . stringify ( evidence . error ?? evidence . normalized_result ? . steps ? . map (({ outcome , reason } ) => ({ outcome, reason })))}` ,
2026-10-04 16:02:36 +08:00
);
scenarioStatus = "selected-AX-button-action-and-CLI-parity-observed" ;
2026-10-09 06:48:36 +08:00
await withArtifactMcp ( executable , async ( client ) => {
const observed = await artifactMcpResult ( client , "observe_native_ui" , {
... scope ,
screenshot : false ,
});
if (
! observed . initial . nodes . some (
( node ) =>
node . role === "AXButton" &&
JSON . stringify ( node . path ) === JSON . stringify ( button . path ),
)
)
throw new Error ( "MCP omitted the selected fixture's actual AX button" );
const captured = await artifactMcpResult (
client ,
"capture_native_ui_scenario" ,
{
... scope ,
screenshot : false ,
steps : [
{ kind : "click" , path : button . path },
{ kind : "wait" , milliseconds : 200 },
],
},
);
if (
captured . steps . length !== 2 ||
captured . steps . some (( step ) => step . outcome !== "completed" ) ||
! captured . steps
. at ( - 1 )
? . after ? . nodes . some (
( node ) => node . title === "REA fixture incremented" ,
)
)
throw new Error ( "MCP did not observe the selected fixture's AX action" );
});
scenarioStatus = "selected-AX-button-action-and-CLI-MCP-parity-observed" ;
2026-10-04 16:02:36 +08:00
}
const mismatch = await observeNativeUi (
{ ... target . value , sha256 : "0" . repeat ( 64 ) },
"observe_native_ui" ,
2026-10-05 20:16:44 +08:00
scope ,
2026-10-09 20:02:52 +08:00
{ environment : process . env },
2026-10-04 16:02:36 +08:00
);
if ( mismatch . ok || ! mismatch . error . message . includes ( "target-mismatch" ))
throw new Error ( "Real helper did not reject changed target bytes" );
2026-10-09 03:30:07 +08:00
report = {
ok : true ,
verification_status : observation . ok ? "passed" : "permission-boundary-only" ,
positive_e2e : observation . ok ,
observation : observation . ok
? "captured-selected-fixture-window"
: "os-permission-denial-verified" ,
scenario : scenarioStatus ,
permission_failure : observation . ok ? null : observation . error . message ,
mismatch_rejected : true ,
target_owned : true ,
};
2026-10-04 16:02:36 +08:00
} finally {
2026-10-09 06:48:36 +08:00
if ( fixturePid !== undefined && fixtureIdentity ? . state === "readable" ) {
const current = await observeProcessStartIdentity ( fixturePid );
if ( current !== undefined ) {
assert . ok (
current . state === "readable" &&
current . identity === fixtureIdentity . identity ,
"Fixture PID identity changed; refusing to signal another process" ,
);
try {
process . kill ( fixturePid , "SIGTERM" );
} catch ( cause ) {
if ( cause . code !== "ESRCH" ) throw cause ;
}
2026-10-04 16:02:36 +08:00
}
}
if ( child !== undefined && child . exitCode === null ) {
child . kill ( "SIGTERM" );
2026-10-09 06:48:36 +08:00
await childExit ;
2026-10-04 16:02:36 +08:00
}
await rm ( root , { recursive : true , force : true });
}
2026-10-09 03:30:07 +08:00
process . stdout . write ( ` ${ JSON . stringify ( report ) } \n` );