rnxsim 0.1.353 → 0.1.355
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/bridge-flow-runner.ts +44 -51
- package/cli/commands/inspect/settling.ts +9 -2
- package/cli/outbound-endpoints.ts +1 -1
- package/detox/index.ts +144 -74
- package/detox/proof-frame.cjs +107 -0
- package/dist-cli/bin.js +5 -5
- package/dist-cli/chunks/{agent-YTAX6OQQ.js → agent-2MW7ZKUX.js} +2 -2
- package/dist-cli/chunks/{agent-wrapper-2QSUXMNK.js → agent-wrapper-XYQBKV24.js} +2 -2
- package/dist-cli/chunks/{app-fonts-4EVHPXVG.js → app-fonts-SXIZFMPY.js} +2 -2
- package/dist-cli/chunks/{assert-IXMSQLVY.js → assert-R324GCRG.js} +2 -2
- package/dist-cli/chunks/{auth-6QFWU2T5.js → auth-INKYLYRZ.js} +2 -2
- package/dist-cli/chunks/{beta-27I5WWRE.js → beta-BQHBSKA5.js} +2 -2
- package/dist-cli/chunks/{camera-4HFUBXPM.js → camera-UPRFBRPF.js} +2 -2
- package/dist-cli/chunks/{chunk-ZJ4O636U.js → chunk-23TG7DLH.js} +1 -1
- package/dist-cli/chunks/{chunk-3XDGLCNP.js → chunk-247ZSWTI.js} +2 -2
- package/dist-cli/chunks/chunk-2GLEESND.js +535 -0
- package/dist-cli/chunks/{chunk-6JBOKETZ.js → chunk-2Z2S3C6A.js} +2 -2
- package/dist-cli/chunks/{chunk-ELBDKQK2.js → chunk-42LIUAHU.js} +2 -2
- package/dist-cli/chunks/{chunk-PBTG4UCS.js → chunk-6EC3D5IG.js} +1 -1
- package/dist-cli/chunks/{chunk-3UXMNDMH.js → chunk-6WCDWBG6.js} +2 -2
- package/dist-cli/chunks/{chunk-5DLBRZAG.js → chunk-7URIRES5.js} +2 -2
- package/dist-cli/chunks/{chunk-HVLZYUGF.js → chunk-AY4OA5UI.js} +2 -2
- package/dist-cli/chunks/{chunk-YYYWU6LL.js → chunk-BUOAYILA.js} +12 -5
- package/dist-cli/chunks/{chunk-KIQVNLVD.js → chunk-CK3M7MAL.js} +2 -2
- package/dist-cli/chunks/{chunk-KZLGVYCX.js → chunk-CRE3V3EG.js} +1 -1
- package/dist-cli/chunks/{chunk-7FUSVOMH.js → chunk-CUXVUSZS.js} +2 -2
- package/dist-cli/chunks/{chunk-4IXIONDR.js → chunk-EQEEPP5A.js} +2 -2
- package/dist-cli/chunks/{chunk-ZYGWIVW4.js → chunk-EZXNGHZT.js} +2 -2
- package/dist-cli/chunks/{chunk-WKQ6W74M.js → chunk-F6S4Z7N7.js} +1 -1
- package/dist-cli/chunks/{chunk-4NPZ5FO2.js → chunk-FDI226G4.js} +2 -2
- package/dist-cli/chunks/{chunk-VZDW2U64.js → chunk-FF2CEKZF.js} +2 -2
- package/dist-cli/chunks/{chunk-HZTWQZUD.js → chunk-FFNFGVGH.js} +2 -2
- package/dist-cli/chunks/{chunk-OEU2MEZQ.js → chunk-FPWDC2DE.js} +1 -1
- package/dist-cli/chunks/{chunk-ZF53H457.js → chunk-GE7GYFE4.js} +2 -2
- package/dist-cli/chunks/{chunk-LMFAIAA5.js → chunk-GFDFVKVW.js} +1 -1
- package/dist-cli/chunks/{chunk-WTGQUQ32.js → chunk-GWL4KMTK.js} +2 -2
- package/dist-cli/chunks/{chunk-JVVO63QH.js → chunk-IAVHAWDL.js} +1 -1
- package/dist-cli/chunks/{chunk-IX7W5BXL.js → chunk-IJGL6T4M.js} +2 -2
- package/dist-cli/chunks/{chunk-LAFKQUT7.js → chunk-JE6NXNGL.js} +3 -3
- package/dist-cli/chunks/{chunk-6NE5Z6KG.js → chunk-JP2YS7LC.js} +1 -1
- package/dist-cli/chunks/{chunk-UNBVUOIC.js → chunk-JYQNEVHV.js} +2 -2
- package/dist-cli/chunks/{chunk-MA5E6HJR.js → chunk-KLVSEUJP.js} +2 -2
- package/dist-cli/chunks/{chunk-T2ZZSSCP.js → chunk-LJCBPR7S.js} +2 -2
- package/dist-cli/chunks/{chunk-WU25RTIX.js → chunk-LQEC53JM.js} +2 -2
- package/dist-cli/chunks/{chunk-7V5O6I4Q.js → chunk-MCAQZI3R.js} +1 -1
- package/dist-cli/chunks/{chunk-UEILOAPW.js → chunk-MKHCGQSF.js} +1 -1
- package/dist-cli/chunks/{chunk-PNQ33FJB.js → chunk-N532KCKW.js} +2 -2
- package/dist-cli/chunks/{chunk-TX2T5KPQ.js → chunk-NCF3QNJG.js} +2 -2
- package/dist-cli/chunks/{chunk-GAYCF2O5.js → chunk-PYSIEEKP.js} +1 -1
- package/dist-cli/chunks/{chunk-ECIDT6F5.js → chunk-QWJD2CAJ.js} +1 -1
- package/dist-cli/chunks/{chunk-XVLYLZOM.js → chunk-QYIT7H6Q.js} +2 -2
- package/dist-cli/chunks/{chunk-RRX5RZI2.js → chunk-S27I7H2K.js} +1 -1
- package/dist-cli/chunks/{chunk-ZXHAAVTH.js → chunk-S4K3TDYB.js} +2 -2
- package/dist-cli/chunks/{chunk-4PV6QASJ.js → chunk-SEWNI4GS.js} +1 -1
- package/dist-cli/chunks/{chunk-3IWR7KHR.js → chunk-SJEAA2FW.js} +2 -2
- package/dist-cli/chunks/{chunk-BK5TJXUU.js → chunk-TADYO7TY.js} +2 -2
- package/dist-cli/chunks/chunk-UIBR3GXC.js +4 -0
- package/dist-cli/chunks/{chunk-CMQIFA4L.js → chunk-VEMZUXVR.js} +1 -1
- package/dist-cli/chunks/{chunk-YQJOM55S.js → chunk-VWPXXB6U.js} +1 -1
- package/dist-cli/chunks/{chunk-RMOSWLWG.js → chunk-W3QLC24Z.js} +2 -2
- package/dist-cli/chunks/{chunk-WV3TKN7D.js → chunk-WUYI4PVY.js} +1 -1
- package/dist-cli/chunks/{chunk-VLI54BKH.js → chunk-X36BQCH5.js} +2 -2
- package/dist-cli/chunks/{chunk-57YZEJ6J.js → chunk-XFPGXEKW.js} +1 -1
- package/dist-cli/chunks/{chunk-DSBUGNC5.js → chunk-XZJNPATS.js} +2 -2
- package/dist-cli/chunks/{chunk-CYS3D7RG.js → chunk-Y3D3DCQV.js} +2 -2
- package/dist-cli/chunks/{chunk-V7X543SB.js → chunk-Y5BP72QO.js} +1 -1
- package/dist-cli/chunks/{chunk-HWMBTYJX.js → chunk-ZNWLFYZK.js} +2 -2
- package/dist-cli/chunks/{chunk-7SKQRS74.js → chunk-ZOFRHAGX.js} +1 -1
- package/dist-cli/chunks/{chunk-UQBXA3AK.js → chunk-ZQF3PK76.js} +2 -2
- package/dist-cli/chunks/{cleanup-ULH3BHAE.js → cleanup-5FGGUP6U.js} +2 -2
- package/dist-cli/chunks/cli-version-JP47UGY6.js +4 -0
- package/dist-cli/chunks/{compat-ONN5DOHB.js → compat-J4OEALGF.js} +2 -2
- package/dist-cli/chunks/{config-OPZU73MX.js → config-MCH2R7WG.js} +2 -2
- package/dist-cli/chunks/{control-WNYEAI4K.js → control-NPWFRKZS.js} +2 -2
- package/dist-cli/chunks/daemon-M26NJFNC.js +4 -0
- package/dist-cli/chunks/{debug-GNI3HDAK.js → debug-7NHPYUX6.js} +3 -3
- package/dist-cli/chunks/{desktop-DF65PDJG.js → desktop-EIBJC5EU.js} +3 -3
- package/dist-cli/chunks/{detox-N267KW2Y.js → detox-ZFEQKHDT.js} +2 -2
- package/dist-cli/chunks/{device-RFU44PE3.js → device-JM7QMQFQ.js} +2 -2
- package/dist-cli/chunks/{diagnose-XR2JIGDE.js → diagnose-DCMU2GAD.js} +2 -2
- package/dist-cli/chunks/{disk-cleanup-DPY6TYXA.js → disk-cleanup-RPW5MYGM.js} +2 -2
- package/dist-cli/chunks/drivers-DW3CQUTK.js +4 -0
- package/dist-cli/chunks/{film-NQQZPZ2W.js → film-NRGBZSC7.js} +3 -3
- package/dist-cli/chunks/flow-76SLGQCI.js +4 -0
- package/dist-cli/chunks/help-GUYB5XWH.js +4 -0
- package/dist-cli/chunks/{hidden-runtime-alias-APCBIXJ6.js → hidden-runtime-alias-GSZG7QFD.js} +2 -2
- package/dist-cli/chunks/{home-paths-27GY5EPD.js → home-paths-OGXLRRTG.js} +2 -2
- package/dist-cli/chunks/inspect-URTBYLXV.js +4 -0
- package/dist-cli/chunks/install-desktop-BINBAGSN.js +4 -0
- package/dist-cli/chunks/{login-6KIO5FR4.js → login-A7JSAMON.js} +2 -2
- package/dist-cli/chunks/{logout-WMCYGTYA.js → logout-EBUWNT63.js} +2 -2
- package/dist-cli/chunks/{maestro-7QLOUZIE.js → maestro-OEXXVYMW.js} +3 -3
- package/dist-cli/chunks/{maestro-generate-QIGCQANR.js → maestro-generate-DKH6IUTC.js} +3 -3
- package/dist-cli/chunks/{mode-3H662MUJ.js → mode-YKX2UTBX.js} +2 -2
- package/dist-cli/chunks/{optional-demo-registry-FQV54ASP.js → optional-demo-registry-W4Z54NYF.js} +2 -2
- package/dist-cli/chunks/{perf-KC6LOGFD.js → perf-EJDB2NWH.js} +2 -2
- package/dist-cli/chunks/{permissions-UGVHA3L6.js → permissions-MXUT3YR6.js} +2 -2
- package/dist-cli/chunks/privacy-JIJOFIGM.js +4 -0
- package/dist-cli/chunks/{record-V72Q363I.js → record-STFUL6CS.js} +3 -3
- package/dist-cli/chunks/{report-issue-MILOOCMG.js → report-issue-TE2QTEMZ.js} +2 -2
- package/dist-cli/chunks/reset-LAIGONON.js +4 -0
- package/dist-cli/chunks/runtime-ZS3FBQLG.js +4 -0
- package/dist-cli/chunks/{screenshot-command-FYMZF5AF.js → screenshot-command-SRW3HXF7.js} +3 -3
- package/dist-cli/chunks/{screenshot-layers-OXLW6GEJ.js → screenshot-layers-IULR35XJ.js} +2 -2
- package/dist-cli/chunks/{screenshots-capture-N3LKTUKB.js → screenshots-capture-54GFQLXS.js} +2 -2
- package/dist-cli/chunks/{serve-USDREK3N.js → serve-WJAOT43K.js} +2 -2
- package/dist-cli/chunks/setup-36ACJ5FT.js +4 -0
- package/dist-cli/chunks/{skills-3MIZF3DS.js → skills-DW5GLHAT.js} +2 -2
- package/dist-cli/chunks/{state-MCFLWQHV.js → state-IW4THIPU.js} +2 -2
- package/dist-cli/chunks/{storage-KXNS5WEC.js → storage-F4RQZFTZ.js} +2 -2
- package/dist-cli/chunks/store-MTN4TPL6.js +4 -0
- package/dist-cli/chunks/telemetry-CJSVYHLI.js +4 -0
- package/dist-cli/chunks/{timeline-QJ3USMSX.js → timeline-IX3HVS22.js} +2 -2
- package/dist-cli/chunks/{upgrade-LXFD4DFX.js → upgrade-T4LWQ2JA.js} +2 -2
- package/dist-cli/chunks/upload-LHWECFWH.js +4 -0
- package/dist-cli/chunks/{version-D5DLNPHP.js → version-MNRMHJ64.js} +2 -2
- package/dist-cli/chunks/{web-FRFNTTZ4.js → web-ARKJI3PY.js} +2 -2
- package/dist-cli/chunks/welcome-XH657K2V.js +5 -0
- package/dist-cli/chunks/{what-happened-FJ6GRQAS.js → what-happened-EBK5NZFI.js} +2 -2
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +196 -54
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +1 -1
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +10 -3
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +10 -3
- package/dist-lib/sdk.mjs +10 -3
- package/dist-lib/skills.cjs +4735 -196
- package/dist-lib/vite.cjs +1 -1
- package/package.json +1 -1
- package/dist-cli/chunks/chunk-37J74ASV.js +0 -4
- package/dist-cli/chunks/chunk-R4KBCGXX.js +0 -543
- package/dist-cli/chunks/cli-version-I2E56I4W.js +0 -4
- package/dist-cli/chunks/daemon-5J6V2GLG.js +0 -4
- package/dist-cli/chunks/drivers-OIZC544W.js +0 -4
- package/dist-cli/chunks/flow-3NHEHRF4.js +0 -4
- package/dist-cli/chunks/help-VACWNWEH.js +0 -4
- package/dist-cli/chunks/inspect-2JELKZ5A.js +0 -4
- package/dist-cli/chunks/install-desktop-4R7UTGQU.js +0 -4
- package/dist-cli/chunks/privacy-E6KH2DRJ.js +0 -4
- package/dist-cli/chunks/reset-LEL5ZGFN.js +0 -4
- package/dist-cli/chunks/runtime-RT2OTIQC.js +0 -4
- package/dist-cli/chunks/setup-QC7W7NCL.js +0 -4
- package/dist-cli/chunks/store-GL6DFPJL.js +0 -4
- package/dist-cli/chunks/telemetry-OOW5FMZQ.js +0 -4
- package/dist-cli/chunks/upload-EUEO4D5N.js +0 -4
- package/dist-cli/chunks/welcome-YYYGS5WD.js +0 -5
|
@@ -2,6 +2,10 @@ import * as fs from 'fs'
|
|
|
2
2
|
import * as path from 'path'
|
|
3
3
|
import { devices, type DeviceModel } from 'sootsim-engine/settings'
|
|
4
4
|
import yaml from 'yaml'
|
|
5
|
+
import {
|
|
6
|
+
REQUIRED_IDENTICAL_PROOF_FRAMES,
|
|
7
|
+
framesIdentical,
|
|
8
|
+
} from '../detox/proof-frame.cjs'
|
|
5
9
|
import { composeFramedScreenshot } from '../src/screenshots/frame-compose'
|
|
6
10
|
import { resetGuestAppStateEval } from './app-state-reset'
|
|
7
11
|
import {
|
|
@@ -415,7 +419,6 @@ export class SootSimBridgeFlowRunner {
|
|
|
415
419
|
private lastRecordingDurationMs: number | null = null
|
|
416
420
|
private lastRecordingFrameStats: unknown = null
|
|
417
421
|
private flowTraceSteps: SootSimFlowTraceStep[] = []
|
|
418
|
-
private lastVisualSettledAtMs = 0
|
|
419
422
|
private simRouteHint: string | null = null
|
|
420
423
|
// set by runStep when a step throws — lets the outer flow command
|
|
421
424
|
// dump a rich per-step failure bundle (screenshot + describe.json +
|
|
@@ -1489,23 +1492,6 @@ export class SootSimBridgeFlowRunner {
|
|
|
1489
1492
|
layers?: 'full' | 'tenant' | 'shell'
|
|
1490
1493
|
},
|
|
1491
1494
|
): Promise<string> {
|
|
1492
|
-
// give transitions + last-moment data paint a brief settle window before
|
|
1493
|
-
// freezing the frame. screenshot flows care more about visual completeness
|
|
1494
|
-
// than minimum latency. waitForVisualSettle drains in-flight animations
|
|
1495
|
-
// (it returns immediately if nothing is animating), then we hold for an
|
|
1496
|
-
// additional 250ms paint window for fade-ins / image decodes / etc.
|
|
1497
|
-
// before this change, a 250ms sleep was the *only* settle window — flows
|
|
1498
|
-
// that took a screenshot at the tail of a screen transition would catch
|
|
1499
|
-
// a half-finished frame on slow machines.
|
|
1500
|
-
if (!this.hasFreshVisualSettle()) {
|
|
1501
|
-
try {
|
|
1502
|
-
await this.waitForVisualSettle()
|
|
1503
|
-
await sleep(250)
|
|
1504
|
-
} catch {
|
|
1505
|
-
// settling helper can throw on disconnected sims; fall through so a
|
|
1506
|
-
// stale sim still produces *some* screenshot.
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
1495
|
// flow `takeScreenshot: <name>` may be a plain name ("hero") or a full
|
|
1510
1496
|
// relative path with slashes ("apps/foo/screenshots/en/02-forum") —
|
|
1511
1497
|
// maestro flows commonly use the latter. mkdir the parent of the
|
|
@@ -1531,9 +1517,32 @@ export class SootSimBridgeFlowRunner {
|
|
|
1531
1517
|
simId: this.opts.simId,
|
|
1532
1518
|
}
|
|
1533
1519
|
if (layers && layers !== 'full') screenshotRequest.layers = layers
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1520
|
+
let previousBuffer: Buffer | null = null
|
|
1521
|
+
let identicalFrames = 1
|
|
1522
|
+
let rawBuffer: Buffer | null = null
|
|
1523
|
+
for (let attempt = 0; attempt < 12; attempt++) {
|
|
1524
|
+
await this.waitForVisualSettle()
|
|
1525
|
+
const dataUrl: string = await this.bridge.send(screenshotRequest)
|
|
1526
|
+
const current = Buffer.from(
|
|
1527
|
+
dataUrl.replace(/^data:image\/png;base64,/, ''),
|
|
1528
|
+
'base64',
|
|
1529
|
+
)
|
|
1530
|
+
if (framesIdentical(previousBuffer, current)) {
|
|
1531
|
+
identicalFrames++
|
|
1532
|
+
if (identicalFrames >= REQUIRED_IDENTICAL_PROOF_FRAMES) {
|
|
1533
|
+
rawBuffer = current
|
|
1534
|
+
break
|
|
1535
|
+
}
|
|
1536
|
+
} else {
|
|
1537
|
+
identicalFrames = 1
|
|
1538
|
+
}
|
|
1539
|
+
previousBuffer = current
|
|
1540
|
+
}
|
|
1541
|
+
if (!rawBuffer) {
|
|
1542
|
+
throw new Error(
|
|
1543
|
+
`screenshot did not reach ${REQUIRED_IDENTICAL_PROOF_FRAMES} identical idle frames`,
|
|
1544
|
+
)
|
|
1545
|
+
}
|
|
1537
1546
|
if (spec.withFrame) {
|
|
1538
1547
|
const model = await this.readCurrentDeviceModel()
|
|
1539
1548
|
if (!model) {
|
|
@@ -1563,13 +1572,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1563
1572
|
}
|
|
1564
1573
|
|
|
1565
1574
|
async captureScreenshot(outputPath: string) {
|
|
1566
|
-
|
|
1567
|
-
const dataUrl: string = await this.bridge.send({
|
|
1568
|
-
type: 'screenshot',
|
|
1569
|
-
simId: this.opts.simId,
|
|
1570
|
-
})
|
|
1571
|
-
const base64 = dataUrl.replace(/^data:image\/png;base64,/, '')
|
|
1572
|
-
fs.writeFileSync(outputPath, Buffer.from(base64, 'base64'))
|
|
1575
|
+
await this.takeScreenshot({ path: outputPath })
|
|
1573
1576
|
}
|
|
1574
1577
|
|
|
1575
1578
|
// writes a per-failure debug bundle: screenshot + structured describe
|
|
@@ -1880,7 +1883,9 @@ export class SootSimBridgeFlowRunner {
|
|
|
1880
1883
|
stablePolls: 2,
|
|
1881
1884
|
strict: true,
|
|
1882
1885
|
})
|
|
1883
|
-
if (result.settled)
|
|
1886
|
+
if (!result.settled) {
|
|
1887
|
+
throw new Error(`animation did not settle within ${maxMs}ms`)
|
|
1888
|
+
}
|
|
1884
1889
|
}
|
|
1885
1890
|
|
|
1886
1891
|
async back() {
|
|
@@ -2082,31 +2087,19 @@ export class SootSimBridgeFlowRunner {
|
|
|
2082
2087
|
}
|
|
2083
2088
|
|
|
2084
2089
|
private async waitForVisualSettle() {
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
}
|
|
2095
|
-
this.markVisualSettled()
|
|
2096
|
-
} catch {
|
|
2097
|
-
await sleep(180)
|
|
2098
|
-
this.markVisualSettled()
|
|
2090
|
+
const result = await waitForSootsimIdle({
|
|
2091
|
+
bridge: this.bridge,
|
|
2092
|
+
simId: this.opts.simId,
|
|
2093
|
+
maxMs: 10_000,
|
|
2094
|
+
pollMs: 32,
|
|
2095
|
+
stablePolls: 3,
|
|
2096
|
+
strict: true,
|
|
2097
|
+
})
|
|
2098
|
+
if (!result.settled) {
|
|
2099
|
+
throw new Error(`visual state did not settle within ${result.elapsed}ms`)
|
|
2099
2100
|
}
|
|
2100
2101
|
}
|
|
2101
2102
|
|
|
2102
|
-
private markVisualSettled() {
|
|
2103
|
-
this.lastVisualSettledAtMs = Date.now()
|
|
2104
|
-
}
|
|
2105
|
-
|
|
2106
|
-
private hasFreshVisualSettle() {
|
|
2107
|
-
return Date.now() - this.lastVisualSettledAtMs < 750
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
2103
|
private resolveShellLaunchAppId(opts: unknown, state: ShellState | null): string {
|
|
2111
2104
|
if (typeof opts === 'string' && opts.length > 0) return opts
|
|
2112
2105
|
if (isObjectRecord(opts)) {
|
|
@@ -58,16 +58,21 @@ export async function waitForSootsimIdle({
|
|
|
58
58
|
|
|
59
59
|
const readSnapshot = async () => {
|
|
60
60
|
let animating = false
|
|
61
|
+
let layoutDirty = false
|
|
61
62
|
let pendingFetches = 0
|
|
62
63
|
let requestTotal = 0
|
|
63
64
|
let requestInFlight = 0
|
|
65
|
+
let renderStatsAvailable = false
|
|
64
66
|
try {
|
|
65
67
|
const stats = await window.__sootsimRenderHost?.queryStats?.()
|
|
66
68
|
if (stats) {
|
|
69
|
+
renderStatsAvailable = true
|
|
67
70
|
animating =
|
|
68
71
|
stats.hasActiveAnims === true ||
|
|
69
72
|
stats.hasActiveNativeAnimations === true ||
|
|
70
73
|
stats.hasPendingAnimationFrames === true
|
|
74
|
+
layoutDirty =
|
|
75
|
+
stats.layoutDirty === true && stats.renderRequested === true
|
|
71
76
|
// a freshly-pushed screen showing a skeleton is layout-stable
|
|
72
77
|
// but not actually settled — its data/images are still in
|
|
73
78
|
// flight. treat bounded image-loader fetches as not-idle so
|
|
@@ -94,7 +99,7 @@ export async function waitForSootsimIdle({
|
|
|
94
99
|
}
|
|
95
100
|
walk(root)
|
|
96
101
|
}
|
|
97
|
-
return { layout: nodes.join(';'), animating, pendingFetches, requestTotal, requestInFlight }
|
|
102
|
+
return { layout: nodes.join(';'), animating, layoutDirty, pendingFetches, renderStatsAvailable, requestTotal, requestInFlight }
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
// how long generic background network is allowed to keep us waiting AFTER
|
|
@@ -114,7 +119,9 @@ export async function waitForSootsimIdle({
|
|
|
114
119
|
let visuallyStillSince = 0
|
|
115
120
|
while (Date.now() < deadline) {
|
|
116
121
|
const snapshot = await readSnapshot()
|
|
117
|
-
const strictOk =
|
|
122
|
+
const strictOk =
|
|
123
|
+
!strict ||
|
|
124
|
+
(snapshot.renderStatsAvailable && !snapshot.animating && !snapshot.layoutDirty)
|
|
118
125
|
// visually settled: no animations, no image content loading, layout
|
|
119
126
|
// unchanged since the last poll.
|
|
120
127
|
const visuallyStill =
|
|
@@ -125,7 +125,7 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
125
125
|
count: 3,
|
|
126
126
|
},
|
|
127
127
|
'packages/sootsim-engine/src/components/native-ui/LottieHost.tsx :: fetch :: descriptor.value':
|
|
128
|
-
{ category: 'guest_app_network', count:
|
|
128
|
+
{ category: 'guest_app_network', count: 2 },
|
|
129
129
|
'packages/sootsim-engine/src/demo/demo-share-register.ts :: fetch :: shareUrl': {
|
|
130
130
|
category: 'guest_app_network',
|
|
131
131
|
count: 1,
|
package/detox/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import * as fs from 'fs'
|
|
|
6
6
|
import * as path from 'path'
|
|
7
7
|
import { chromium } from 'playwright'
|
|
8
8
|
import { launchReapedChromeScoped } from '../../../scripts/lib/reap-browser'
|
|
9
|
+
import { pollUntil } from '../src/poll-until'
|
|
9
10
|
import { createExpect, createWaitFor } from './expectations'
|
|
10
11
|
import {
|
|
11
12
|
dispatchTwoFingerGesture,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
type MotionChangeDefinition,
|
|
23
24
|
type MotionChangeMeasurement,
|
|
24
25
|
} from './motion-change.cjs'
|
|
26
|
+
import { REQUIRED_IDENTICAL_PROOF_FRAMES, framesIdentical } from './proof-frame.cjs'
|
|
25
27
|
import type { SootSimElement } from './element-types'
|
|
26
28
|
import type { Browser, BrowserContext, Page } from 'playwright'
|
|
27
29
|
import type { SootsimEventMap, SootsimEventName } from 'sootsim-engine/sootsim-event'
|
|
@@ -42,6 +44,7 @@ let _reapBrowserNow: (() => void) | null = null
|
|
|
42
44
|
let _context: BrowserContext | null = null
|
|
43
45
|
let _page: Page | null = null
|
|
44
46
|
let _synchronizationEnabled = false
|
|
47
|
+
let _suspendedAppId: string | null = null
|
|
45
48
|
const pageDiagnostics = new WeakMap<Page, string[]>()
|
|
46
49
|
|
|
47
50
|
const STATUS_BAR_OVERRIDE_EVENT = 'sootsim:statusBarOverride'
|
|
@@ -339,9 +342,14 @@ async function closeBrowser() {
|
|
|
339
342
|
}
|
|
340
343
|
}
|
|
341
344
|
|
|
342
|
-
export async function waitForSootsimIdle(
|
|
345
|
+
export async function waitForSootsimIdle(
|
|
346
|
+
page: Page,
|
|
347
|
+
maxMs = 3000,
|
|
348
|
+
strict = false,
|
|
349
|
+
): Promise<void> {
|
|
343
350
|
const result = await page.evaluate(
|
|
344
|
-
async ({ maxMs }) => {
|
|
351
|
+
async ({ maxMs, strict }) => {
|
|
352
|
+
const start = Date.now()
|
|
345
353
|
let transitionError: string | null = null
|
|
346
354
|
try {
|
|
347
355
|
const waitForScreenTransitions = (window as any).__sootsimTest
|
|
@@ -352,13 +360,6 @@ export async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void
|
|
|
352
360
|
settleMs: 48,
|
|
353
361
|
startWindowMs: 600,
|
|
354
362
|
})
|
|
355
|
-
if (
|
|
356
|
-
transitionResult?.started === true &&
|
|
357
|
-
transitionResult.settled === true &&
|
|
358
|
-
transitionResult.timedOut !== true
|
|
359
|
-
) {
|
|
360
|
-
return { settled: true, elapsed: transitionResult.waitedMs ?? 0 }
|
|
361
|
-
}
|
|
362
363
|
if (transitionResult?.timedOut) {
|
|
363
364
|
return {
|
|
364
365
|
settled: false,
|
|
@@ -371,7 +372,6 @@ export async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void
|
|
|
371
372
|
transitionError = error instanceof Error ? error.message : String(error)
|
|
372
373
|
}
|
|
373
374
|
|
|
374
|
-
const start = Date.now()
|
|
375
375
|
const deadline = start + maxMs
|
|
376
376
|
const pollMs = 50
|
|
377
377
|
const requiredStablePolls = 3
|
|
@@ -388,6 +388,28 @@ export async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void
|
|
|
388
388
|
const readSnapshot = async () => {
|
|
389
389
|
const root = (window as any).__sootsimRoot
|
|
390
390
|
const layout: number[] = []
|
|
391
|
+
let animating = false
|
|
392
|
+
let auxSurfaceDebug: unknown = null
|
|
393
|
+
let layoutDirty = false
|
|
394
|
+
let renderStatsAvailable = false
|
|
395
|
+
if (strict) {
|
|
396
|
+
try {
|
|
397
|
+
const renderHost: unknown = Reflect.get(window, '__sootsimRenderHost')
|
|
398
|
+
const queryStats =
|
|
399
|
+
typeof renderHost === 'object' && renderHost !== null
|
|
400
|
+
? Reflect.get(renderHost, 'queryStats')
|
|
401
|
+
: null
|
|
402
|
+
const stats =
|
|
403
|
+
typeof queryStats === 'function' ? await queryStats.call(renderHost) : null
|
|
404
|
+
renderStatsAvailable = !!stats
|
|
405
|
+
auxSurfaceDebug = stats?.auxSurfaceDebug ?? null
|
|
406
|
+
animating =
|
|
407
|
+
stats?.hasActiveAnims === true ||
|
|
408
|
+
stats?.hasActiveNativeAnimations === true ||
|
|
409
|
+
stats?.hasPendingAnimationFrames === true
|
|
410
|
+
layoutDirty = stats?.layoutDirty === true && stats?.renderRequested === true
|
|
411
|
+
} catch {}
|
|
412
|
+
}
|
|
391
413
|
if (root) {
|
|
392
414
|
const walk = (node: any) => {
|
|
393
415
|
if (node.layout && node.layout.width > 0) {
|
|
@@ -402,20 +424,33 @@ export async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void
|
|
|
402
424
|
}
|
|
403
425
|
walk(root)
|
|
404
426
|
}
|
|
405
|
-
return
|
|
427
|
+
return {
|
|
428
|
+
animating,
|
|
429
|
+
auxSurfaceDebug,
|
|
430
|
+
layout,
|
|
431
|
+
layoutDirty,
|
|
432
|
+
renderStatsAvailable,
|
|
433
|
+
}
|
|
406
434
|
}
|
|
407
435
|
|
|
408
436
|
let stableLayout: number[] | null = null
|
|
409
437
|
let stable = 0
|
|
438
|
+
let lastSnapshot: Awaited<ReturnType<typeof readSnapshot>> | null = null
|
|
410
439
|
while (Date.now() < deadline) {
|
|
411
|
-
const
|
|
412
|
-
|
|
440
|
+
const snapshot = await readSnapshot()
|
|
441
|
+
lastSnapshot = snapshot
|
|
442
|
+
if (
|
|
443
|
+
(!strict || snapshot.renderStatsAvailable) &&
|
|
444
|
+
!snapshot.animating &&
|
|
445
|
+
!snapshot.layoutDirty &&
|
|
446
|
+
isLayoutStable(stableLayout, snapshot.layout)
|
|
447
|
+
) {
|
|
413
448
|
stable++
|
|
414
449
|
if (stable >= requiredStablePolls) {
|
|
415
450
|
return { settled: true, elapsed: Date.now() - start }
|
|
416
451
|
}
|
|
417
452
|
} else {
|
|
418
|
-
stableLayout = layout
|
|
453
|
+
stableLayout = snapshot.layout
|
|
419
454
|
stable = 0
|
|
420
455
|
}
|
|
421
456
|
await sleep(pollMs)
|
|
@@ -425,10 +460,12 @@ export async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void
|
|
|
425
460
|
elapsed: Date.now() - start,
|
|
426
461
|
reason: transitionError
|
|
427
462
|
? `layout fallback after screen transition wait failed: ${transitionError}`
|
|
428
|
-
:
|
|
463
|
+
: strict
|
|
464
|
+
? `strict idle state unavailable: renderStats=${lastSnapshot?.renderStatsAvailable === true} animating=${lastSnapshot?.animating === true} layoutDirty=${lastSnapshot?.layoutDirty === true} layoutNodes=${(lastSnapshot?.layout.length ?? 0) / 4} aux=${JSON.stringify(lastSnapshot?.auxSurfaceDebug)}`
|
|
465
|
+
: 'layout fallback',
|
|
429
466
|
}
|
|
430
467
|
},
|
|
431
|
-
{ maxMs },
|
|
468
|
+
{ maxMs, strict },
|
|
432
469
|
)
|
|
433
470
|
|
|
434
471
|
if (!result?.settled) {
|
|
@@ -633,7 +670,7 @@ function writeFastScreenshotFrames<T extends FastScreenshotData>(
|
|
|
633
670
|
|
|
634
671
|
// single-shot canvas capture — no frame-stability poll. used for multi-stage
|
|
635
672
|
// animation captures where we explicitly want to sample mid-transition. the
|
|
636
|
-
// usual captureSootsimPng
|
|
673
|
+
// usual captureSootsimPng requires three consecutive idle frames to be byte-equal
|
|
637
674
|
// which is ideal for settled screenshots but blocks for the full timeout
|
|
638
675
|
// while an animation is running.
|
|
639
676
|
async function captureSootsimPngFast(opts?: {
|
|
@@ -661,62 +698,25 @@ async function captureSootsimPng(opts?: {
|
|
|
661
698
|
crop?: { h: number; w: number; x: number; y: number }
|
|
662
699
|
}) {
|
|
663
700
|
const page = getPage()
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const screenshot = (window as { SootSim?: { bridges?: { screenshot?: unknown } } })
|
|
680
|
-
.SootSim?.bridges?.screenshot as ((opts?: unknown) => Promise<string>) | undefined
|
|
681
|
-
if (typeof screenshot !== 'function') {
|
|
682
|
-
throw new Error('sootsim screenshot bridge is not installed')
|
|
683
|
-
}
|
|
684
|
-
const gapMs = 100
|
|
685
|
-
const maxWaitMs = 8000
|
|
686
|
-
const minAttempts = 3
|
|
687
|
-
const t0 = Date.now()
|
|
688
|
-
let prev = await screenshot(captureOpts)
|
|
689
|
-
let attempts = 1
|
|
690
|
-
while (attempts < minAttempts || Date.now() - t0 < maxWaitMs) {
|
|
691
|
-
await new Promise((r) => setTimeout(r, gapMs))
|
|
692
|
-
const next = await screenshot(captureOpts)
|
|
693
|
-
attempts++
|
|
694
|
-
if (next === prev) {
|
|
695
|
-
return {
|
|
696
|
-
dataUrl: next,
|
|
697
|
-
stable: true,
|
|
698
|
-
attempts,
|
|
699
|
-
elapsedMs: Date.now() - t0,
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
prev = next
|
|
703
|
-
}
|
|
704
|
-
return { dataUrl: prev, stable: false, attempts, elapsedMs: Date.now() - t0 }
|
|
705
|
-
},
|
|
706
|
-
{
|
|
707
|
-
crop: opts?.crop,
|
|
708
|
-
format: 'png',
|
|
709
|
-
},
|
|
710
|
-
)
|
|
711
|
-
if (!result.dataUrl) {
|
|
712
|
-
throw new Error('sootsim screenshot bridge returned an empty image')
|
|
713
|
-
}
|
|
714
|
-
if (!result.stable) {
|
|
715
|
-
console.warn(
|
|
716
|
-
`[sootsim-detox] screenshot did not stabilize after ${result.elapsedMs}ms (${result.attempts} captures); using last frame`,
|
|
717
|
-
)
|
|
701
|
+
const deadline = Date.now() + 8000
|
|
702
|
+
let previous: Buffer | null = null
|
|
703
|
+
let identicalFrames = 1
|
|
704
|
+
let attempts = 0
|
|
705
|
+
while (Date.now() < deadline) {
|
|
706
|
+
await waitForSootsimIdle(page, Math.max(1, deadline - Date.now()), true)
|
|
707
|
+
const current = await captureSootsimPngFast(opts)
|
|
708
|
+
attempts++
|
|
709
|
+
if (framesIdentical(previous, current)) {
|
|
710
|
+
identicalFrames++
|
|
711
|
+
if (identicalFrames >= REQUIRED_IDENTICAL_PROOF_FRAMES) return current
|
|
712
|
+
} else {
|
|
713
|
+
identicalFrames = 1
|
|
714
|
+
}
|
|
715
|
+
previous = current
|
|
718
716
|
}
|
|
719
|
-
|
|
717
|
+
throw new Error(
|
|
718
|
+
`sootsim screenshot did not reach ${REQUIRED_IDENTICAL_PROOF_FRAMES} identical idle frames in ${attempts} captures`,
|
|
719
|
+
)
|
|
720
720
|
}
|
|
721
721
|
|
|
722
722
|
async function writeSootsimScreenshot(
|
|
@@ -892,8 +892,23 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
|
892
892
|
|
|
893
893
|
async replaceText(text: string) {
|
|
894
894
|
const page = getPage()
|
|
895
|
-
await focusElementForTextEntry(el)
|
|
896
895
|
const node = await findNodeByMatcher(matcher)
|
|
896
|
+
if (
|
|
897
|
+
matcher.type === 'id' &&
|
|
898
|
+
typeof node?.nodeId === 'number' &&
|
|
899
|
+
node.nodeId >= 0x40000000
|
|
900
|
+
) {
|
|
901
|
+
const replaced = await page.evaluate(
|
|
902
|
+
async ({ id, value }) => {
|
|
903
|
+
const mainShell = window.SootSim?.bridges?.mainShell
|
|
904
|
+
if (typeof mainShell?.callTestBridge !== 'function') return false
|
|
905
|
+
return mainShell.callTestBridge('replaceTextByTestId', id, value)
|
|
906
|
+
},
|
|
907
|
+
{ id: matcher.value, value: text },
|
|
908
|
+
)
|
|
909
|
+
if (replaced === true) return
|
|
910
|
+
}
|
|
911
|
+
await focusElementForTextEntry(el)
|
|
897
912
|
const value = typeof node?.text === 'string' ? node.text : ''
|
|
898
913
|
for (let i = 0; i < value.length; i++) {
|
|
899
914
|
await page.keyboard.press('Backspace')
|
|
@@ -1267,6 +1282,40 @@ export const device = {
|
|
|
1267
1282
|
// toggled on an existing context.
|
|
1268
1283
|
recordVideoDir?: string
|
|
1269
1284
|
}) {
|
|
1285
|
+
if (
|
|
1286
|
+
_page &&
|
|
1287
|
+
opts?.newInstance === false &&
|
|
1288
|
+
!opts.delete &&
|
|
1289
|
+
!opts.recordVideoDir &&
|
|
1290
|
+
_suspendedAppId
|
|
1291
|
+
) {
|
|
1292
|
+
const page = _page
|
|
1293
|
+
const appId = _suspendedAppId
|
|
1294
|
+
await page.evaluate(
|
|
1295
|
+
(id) => window.SootSim?.bridges?.mainShell?.launchApp?.(id),
|
|
1296
|
+
appId,
|
|
1297
|
+
)
|
|
1298
|
+
await pollUntil(
|
|
1299
|
+
() =>
|
|
1300
|
+
page.evaluate(async (id) => {
|
|
1301
|
+
const state = await window.SootSim?.bridges?.mainShell?.getState?.()
|
|
1302
|
+
return (
|
|
1303
|
+
state?.state === 'app' &&
|
|
1304
|
+
state.activeApp === id &&
|
|
1305
|
+
state.switcherPhase === 'idle'
|
|
1306
|
+
)
|
|
1307
|
+
}, appId),
|
|
1308
|
+
Boolean,
|
|
1309
|
+
{
|
|
1310
|
+
timeoutMs: 10000,
|
|
1311
|
+
label: `SootSim app ${appId} to resume`,
|
|
1312
|
+
},
|
|
1313
|
+
)
|
|
1314
|
+
_suspendedAppId = null
|
|
1315
|
+
await waitForSootsimTree(page, 10000)
|
|
1316
|
+
return
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1270
1319
|
if (!_browser) {
|
|
1271
1320
|
// Conformance renders CanvasKit and must use the real platform GPU. The
|
|
1272
1321
|
// bundled chromium_headless_shell has no GPU and silently falls back to
|
|
@@ -2237,7 +2286,28 @@ export const device = {
|
|
|
2237
2286
|
},
|
|
2238
2287
|
|
|
2239
2288
|
async sendToHome() {
|
|
2240
|
-
|
|
2289
|
+
const page = getPage()
|
|
2290
|
+
_suspendedAppId = await page.evaluate(async () => {
|
|
2291
|
+
const state = await window.SootSim?.bridges?.mainShell?.getState?.()
|
|
2292
|
+
const appId = state?.activeApp
|
|
2293
|
+
if (typeof appId !== 'string') {
|
|
2294
|
+
throw new Error(`cannot send an inactive SootSim app home`)
|
|
2295
|
+
}
|
|
2296
|
+
window.SootSim?.bridges?.mainShell?.goHome?.()
|
|
2297
|
+
return appId
|
|
2298
|
+
})
|
|
2299
|
+
await pollUntil(
|
|
2300
|
+
() =>
|
|
2301
|
+
page.evaluate(async () => {
|
|
2302
|
+
const state = await window.SootSim?.bridges?.mainShell?.getState?.()
|
|
2303
|
+
return state?.state === 'home' && state.switcherPhase === 'idle'
|
|
2304
|
+
}),
|
|
2305
|
+
Boolean,
|
|
2306
|
+
{
|
|
2307
|
+
timeoutMs: 10000,
|
|
2308
|
+
label: 'SootSim home transition',
|
|
2309
|
+
},
|
|
2310
|
+
)
|
|
2241
2311
|
},
|
|
2242
2312
|
|
|
2243
2313
|
// get direct access to the playwright page for advanced use
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// proof captures reserve the top and bottom of a device frame for platform
|
|
2
|
+
// chrome. those pixels can be fully opaque while the app surface is blank or
|
|
3
|
+
// still fading in, so evidence density is measured only in the content band.
|
|
4
|
+
const PROOF_CONTENT_DEFINITION = Object.freeze({
|
|
5
|
+
channelThreshold: 24,
|
|
6
|
+
contentBandInsetFraction: 0.08,
|
|
7
|
+
minimumContentPercent: 0.005,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const REQUIRED_IDENTICAL_PROOF_FRAMES = 3
|
|
11
|
+
|
|
12
|
+
function framesIdentical(a, b) {
|
|
13
|
+
if (!a || !b || a.length !== b.length) return false
|
|
14
|
+
return a.equals(b)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function measureProofFrameContent(input) {
|
|
18
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
19
|
+
throw new Error('proof content measurement requires an input object')
|
|
20
|
+
}
|
|
21
|
+
const { data, width, height } = input
|
|
22
|
+
if (
|
|
23
|
+
!data ||
|
|
24
|
+
typeof data.length !== 'number' ||
|
|
25
|
+
!Number.isInteger(width) ||
|
|
26
|
+
!Number.isInteger(height) ||
|
|
27
|
+
width <= 0 ||
|
|
28
|
+
height <= 0 ||
|
|
29
|
+
data.length !== width * height * 4
|
|
30
|
+
) {
|
|
31
|
+
throw new Error('proof content measurement needs RGBA pixels and dimensions')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const yStart = Math.floor(height * PROOF_CONTENT_DEFINITION.contentBandInsetFraction)
|
|
35
|
+
const yEnd = Math.ceil(
|
|
36
|
+
height * (1 - PROOF_CONTENT_DEFINITION.contentBandInsetFraction),
|
|
37
|
+
)
|
|
38
|
+
const bucketCounts = new Uint32Array(32 * 32 * 32)
|
|
39
|
+
let totalPixels = 0
|
|
40
|
+
for (let y = yStart; y < yEnd; y++) {
|
|
41
|
+
for (let x = 0; x < width; x++) {
|
|
42
|
+
const offset = (y * width + x) * 4
|
|
43
|
+
const bucket =
|
|
44
|
+
((data[offset] >> 3) << 10) |
|
|
45
|
+
((data[offset + 1] >> 3) << 5) |
|
|
46
|
+
(data[offset + 2] >> 3)
|
|
47
|
+
bucketCounts[bucket]++
|
|
48
|
+
totalPixels++
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let dominantBucket = 0
|
|
53
|
+
for (let index = 1; index < bucketCounts.length; index++) {
|
|
54
|
+
if (bucketCounts[index] > bucketCounts[dominantBucket]) dominantBucket = index
|
|
55
|
+
}
|
|
56
|
+
const dominant = {
|
|
57
|
+
red: (((dominantBucket >> 10) & 31) << 3) + 4,
|
|
58
|
+
green: (((dominantBucket >> 5) & 31) << 3) + 4,
|
|
59
|
+
blue: ((dominantBucket & 31) << 3) + 4,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let contentPixels = 0
|
|
63
|
+
for (let y = yStart; y < yEnd; y++) {
|
|
64
|
+
for (let x = 0; x < width; x++) {
|
|
65
|
+
const offset = (y * width + x) * 4
|
|
66
|
+
if (
|
|
67
|
+
Math.max(
|
|
68
|
+
Math.abs(data[offset] - dominant.red),
|
|
69
|
+
Math.abs(data[offset + 1] - dominant.green),
|
|
70
|
+
Math.abs(data[offset + 2] - dominant.blue),
|
|
71
|
+
) >= PROOF_CONTENT_DEFINITION.channelThreshold
|
|
72
|
+
) {
|
|
73
|
+
contentPixels++
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const contentPercent = (contentPixels / totalPixels) * 100
|
|
79
|
+
return {
|
|
80
|
+
contentPercent,
|
|
81
|
+
contentPixels,
|
|
82
|
+
dominant,
|
|
83
|
+
passedMinimum:
|
|
84
|
+
contentPercent >= PROOF_CONTENT_DEFINITION.minimumContentPercent,
|
|
85
|
+
totalPixels,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function settleDecision(previousPixels, currentFrame) {
|
|
90
|
+
if (!currentFrame) return { decision: 'continue', content: null }
|
|
91
|
+
const content = measureProofFrameContent(currentFrame)
|
|
92
|
+
if (!content.passedMinimum) {
|
|
93
|
+
return { decision: 'insufficient-content', content }
|
|
94
|
+
}
|
|
95
|
+
if (previousPixels && framesIdentical(previousPixels, currentFrame.data)) {
|
|
96
|
+
return { decision: 'identical', content }
|
|
97
|
+
}
|
|
98
|
+
return { decision: 'changed', content }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
PROOF_CONTENT_DEFINITION,
|
|
103
|
+
REQUIRED_IDENTICAL_PROOF_FRAMES,
|
|
104
|
+
framesIdentical,
|
|
105
|
+
measureProofFrameContent,
|
|
106
|
+
settleDecision,
|
|
107
|
+
}
|