@@ -3,6 +3,7 @@ import fs from 'node:fs';
33import os from 'node:os' ;
44import path from 'node:path' ;
55import { handleSnapshotCommands } from '../snapshot.ts' ;
6+ import { buildSnapshotState , buildSnapshotVisibility } from '../snapshot-capture.ts' ;
67import { SessionStore } from '../../session-store.ts' ;
78import type { SessionState } from '../../types.ts' ;
89import { AppError } from '../../../utils/errors.ts' ;
@@ -1332,3 +1333,128 @@ test('wait sleep bypasses sessionless runner cleanup wrapper', async () => {
13321333 expect ( response ) . toBeTruthy ( ) ;
13331334 expect ( response ?. ok ) . toBe ( true ) ;
13341335} ) ;
1336+
1337+ // ---------------------------------------------------------------------------
1338+ // Malformed snapshot data – buildSnapshotState robustness
1339+ // ---------------------------------------------------------------------------
1340+
1341+ test ( 'buildSnapshotState handles undefined nodes gracefully' , ( ) => {
1342+ const state = buildSnapshotState ( { nodes : undefined , truncated : undefined } , undefined ) ;
1343+ expect ( state . nodes ) . toEqual ( [ ] ) ;
1344+ expect ( state . truncated ) . toBeUndefined ( ) ;
1345+ expect ( state . createdAt ) . toBeGreaterThan ( 0 ) ;
1346+ } ) ;
1347+
1348+ test ( 'buildSnapshotState handles completely empty data object' , ( ) => {
1349+ const state = buildSnapshotState ( { } , undefined ) ;
1350+ expect ( state . nodes ) . toEqual ( [ ] ) ;
1351+ expect ( state . truncated ) . toBeUndefined ( ) ;
1352+ } ) ;
1353+
1354+ test ( 'buildSnapshotState handles nodes with missing fields' , ( ) => {
1355+ const state = buildSnapshotState (
1356+ {
1357+ nodes : [
1358+ { index : 0 } as any ,
1359+ { index : 1 , depth : undefined , type : undefined , label : undefined } as any ,
1360+ ] ,
1361+ truncated : false ,
1362+ backend : 'android' ,
1363+ } ,
1364+ undefined ,
1365+ ) ;
1366+ expect ( state . nodes ) . toHaveLength ( 2 ) ;
1367+ // Nodes should get refs assigned even with sparse data
1368+ expect ( state . nodes [ 0 ] ?. ref ) . toBeTruthy ( ) ;
1369+ expect ( state . nodes [ 1 ] ?. ref ) . toBeTruthy ( ) ;
1370+ } ) ;
1371+
1372+ test ( 'buildSnapshotState marks comparisonSafe false for filtered Android snapshots' , ( ) => {
1373+ const nodes = [ { index : 0 , depth : 0 , type : 'android.widget.TextView' , label : 'A' } ] ;
1374+
1375+ const interactiveOnly = buildSnapshotState (
1376+ { nodes, backend : 'android' } ,
1377+ { snapshotInteractiveOnly : true } ,
1378+ ) ;
1379+ expect ( interactiveOnly . comparisonSafe ) . toBe ( false ) ;
1380+
1381+ const compact = buildSnapshotState ( { nodes, backend : 'android' } , { snapshotCompact : true } ) ;
1382+ expect ( compact . comparisonSafe ) . toBe ( false ) ;
1383+
1384+ const withDepth = buildSnapshotState ( { nodes, backend : 'android' } , { snapshotDepth : 2 } ) ;
1385+ expect ( withDepth . comparisonSafe ) . toBe ( false ) ;
1386+
1387+ const withScope = buildSnapshotState ( { nodes, backend : 'android' } , { snapshotScope : 'Header' } ) ;
1388+ expect ( withScope . comparisonSafe ) . toBe ( false ) ;
1389+
1390+ const unfiltered = buildSnapshotState ( { nodes, backend : 'android' } , { } ) ;
1391+ expect ( unfiltered . comparisonSafe ) . toBe ( true ) ;
1392+ } ) ;
1393+
1394+ test ( 'buildSnapshotState marks comparisonSafe false for non-Android backends' , ( ) => {
1395+ const nodes = [ { index : 0 , depth : 0 , type : 'Button' , label : 'OK' } ] ;
1396+ const state = buildSnapshotState ( { nodes, backend : 'xctest' } , { } ) ;
1397+ expect ( state . comparisonSafe ) . toBe ( false ) ;
1398+ } ) ;
1399+
1400+ // ---------------------------------------------------------------------------
1401+ // Malformed snapshot data – buildSnapshotVisibility robustness
1402+ // ---------------------------------------------------------------------------
1403+
1404+ test ( 'buildSnapshotVisibility returns non-partial for empty node list' , ( ) => {
1405+ const vis = buildSnapshotVisibility ( { nodes : [ ] , backend : 'android' } ) ;
1406+ expect ( vis . partial ) . toBe ( false ) ;
1407+ expect ( vis . visibleNodeCount ) . toBe ( 0 ) ;
1408+ expect ( vis . totalNodeCount ) . toBe ( 0 ) ;
1409+ expect ( vis . reasons ) . toEqual ( [ ] ) ;
1410+ } ) ;
1411+
1412+ test ( 'buildSnapshotVisibility skips semantic analysis for raw snapshots' , ( ) => {
1413+ const nodes = [
1414+ { ref : 'e1' , index : 0 , depth : 0 , type : 'View' , label : 'Root' , hiddenContentBelow : true } ,
1415+ ] ;
1416+ const vis = buildSnapshotVisibility ( { nodes, backend : 'android' , snapshotRaw : true } ) ;
1417+ expect ( vis . partial ) . toBe ( false ) ;
1418+ expect ( vis . visibleNodeCount ) . toBe ( 1 ) ;
1419+ expect ( vis . totalNodeCount ) . toBe ( 1 ) ;
1420+ expect ( vis . reasons ) . toEqual ( [ ] ) ;
1421+ } ) ;
1422+
1423+ test ( 'buildSnapshotVisibility skips semantic analysis for macos-helper backend' , ( ) => {
1424+ const nodes = [
1425+ { ref : 'e1' , index : 0 , depth : 0 , type : 'AXButton' , label : 'Click Me' } ,
1426+ ] ;
1427+ const vis = buildSnapshotVisibility ( { nodes, backend : 'macos-helper' } ) ;
1428+ expect ( vis . partial ) . toBe ( false ) ;
1429+ expect ( vis . reasons ) . toEqual ( [ ] ) ;
1430+ } ) ;
1431+
1432+ test ( 'buildSnapshotVisibility detects scroll-hidden-above and scroll-hidden-below' , ( ) => {
1433+ const nodes = [
1434+ {
1435+ ref : 'e1' ,
1436+ index : 0 ,
1437+ depth : 0 ,
1438+ type : 'ScrollView' ,
1439+ label : 'Feed' ,
1440+ hiddenContentAbove : true ,
1441+ hiddenContentBelow : true ,
1442+ } ,
1443+ ] ;
1444+ const vis = buildSnapshotVisibility ( { nodes, backend : 'android' } ) ;
1445+ expect ( vis . partial ) . toBe ( true ) ;
1446+ expect ( vis . reasons ) . toContain ( 'scroll-hidden-above' ) ;
1447+ expect ( vis . reasons ) . toContain ( 'scroll-hidden-below' ) ;
1448+ } ) ;
1449+
1450+ test ( 'buildSnapshotVisibility handles nodes with no scroll hints as non-partial' , ( ) => {
1451+ const nodes = [
1452+ { ref : 'e1' , index : 0 , depth : 0 , type : 'Button' , label : 'OK' , hittable : true } ,
1453+ { ref : 'e2' , index : 1 , depth : 0 , type : 'Button' , label : 'Cancel' , hittable : true } ,
1454+ ] ;
1455+ const vis = buildSnapshotVisibility ( { nodes, backend : 'xctest' } ) ;
1456+ expect ( vis . partial ) . toBe ( false ) ;
1457+ expect ( vis . visibleNodeCount ) . toBe ( 2 ) ;
1458+ expect ( vis . totalNodeCount ) . toBe ( 2 ) ;
1459+ expect ( vis . reasons ) . toEqual ( [ ] ) ;
1460+ } ) ;
0 commit comments