simframe 0.9.0 → 0.10.0
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/README.md +28 -3
- package/package.json +1 -1
- package/scripts/check-private.mjs +143 -0
- package/scripts/eval-perception.mjs +248 -0
- package/src/actions.js +150 -11
- package/src/analyze.js +70 -0
- package/src/cli.js +83 -6
- package/src/graph.js +104 -4
- package/src/index.js +218 -3
- package/src/input.js +49 -5
- package/src/matching.js +55 -2
- package/src/metrics.js +105 -8
- package/src/navigate.js +10 -7
- package/src/platform/android.js +1 -1
- package/src/screenmap.js +20 -1
- package/src/view.js +61 -0
package/README.md
CHANGED
|
@@ -255,10 +255,11 @@ nav-bar:
|
|
|
255
255
|
#2 text 201,64 Inbox
|
|
256
256
|
content:
|
|
257
257
|
#3 cell 201,140 Weekly digest
|
|
258
|
-
#4
|
|
258
|
+
#4 field 201,196 Search = weekly ~ weekly|
|
|
259
|
+
#5 switch 201,252 Notifications = 1
|
|
259
260
|
tab-bar:
|
|
260
|
-
#
|
|
261
|
-
#
|
|
261
|
+
#6 text 62,835 Inbox
|
|
262
|
+
#7 text 201,835 Settings
|
|
262
263
|
```
|
|
263
264
|
|
|
264
265
|
Region first, because "Inbox" the title and "Inbox" the tab differ only by where
|
|
@@ -267,6 +268,18 @@ is a selector: whatever this calls `#3`, the next call can tap as `#3` without
|
|
|
267
268
|
describing it. A ref is valid only while that screen is showing — used on a
|
|
268
269
|
different screen it refuses rather than tapping whatever now sits there.
|
|
269
270
|
|
|
271
|
+
`= something` is what the control *contains*, from the accessibility tree, and
|
|
272
|
+
`~ something` is what OCR read off the pixels. Both are printed, and where they
|
|
273
|
+
disagree that is the point: one is authoritative and the other is what is
|
|
274
|
+
actually on screen, and a field mid-edit can legitimately differ. A row with no
|
|
275
|
+
`=` is a control that reports no value, not an empty one.
|
|
276
|
+
|
|
277
|
+
When the elements were recalled from screen memory rather than looked at just
|
|
278
|
+
now, the header says so and how long ago — `elements recalled from 41s ago —
|
|
279
|
+
pass refresh for what is there now`. Identity is cached on purpose, because a
|
|
280
|
+
list with new rows is the same screen; contents are exactly what changes without
|
|
281
|
+
the screen changing, so the age is worth seeing.
|
|
282
|
+
|
|
270
283
|
Three ways to name a control, anywhere one is named:
|
|
271
284
|
|
|
272
285
|
| | |
|
|
@@ -542,8 +555,10 @@ simframe frame --out=now.png # newest frame, native resolution, to a file
|
|
|
542
555
|
simframe strip --count=6 # contact sheet, for an animation
|
|
543
556
|
simframe doctor --strict # any degraded layer is a non-zero exit
|
|
544
557
|
simframe escalations # why simframe still needs a model, by reason
|
|
558
|
+
simframe escalations --session # ...this agent only, not every agent on the device
|
|
545
559
|
simframe hpi # speed and accuracy against a human baseline
|
|
546
560
|
simframe baseline record settings-larger-text --runs=5 # record the human
|
|
561
|
+
simframe input reset # rebuild the HID session, without restarting anything
|
|
547
562
|
simframe start / status / stop [--force] / devices
|
|
548
563
|
simframe ui --device=emulator-5554 # or export SIMFRAME_DEVICE once
|
|
549
564
|
```
|
|
@@ -646,6 +661,16 @@ said a word — the exact failure shape, found by the thing built to catch it.
|
|
|
646
661
|
dramatically between visits will simply be rebuilt.
|
|
647
662
|
- It speeds up *confirming* a fix, not *locating* one. A bug living in a memo
|
|
648
663
|
comparator or a stale closure is not visible in any frame.
|
|
664
|
+
- A switch is tapped at the centre of its frame, and a switch's frame is the
|
|
665
|
+
whole row — so the tap lands on the label and the control, which sits at the
|
|
666
|
+
trailing end, does not move. Use `@x,y` on the control for now. Filed with
|
|
667
|
+
the measurement in `docs/DEFERRED.md`; it is a role-specific tap point, not a
|
|
668
|
+
patch at one call site.
|
|
669
|
+
- The simulator's display pipeline stops rendering under rapid app relaunch —
|
|
670
|
+
about six cycles, reproducibly — and every frame comes back black while
|
|
671
|
+
`simctl` itself reports success. simframe now says so instead of reading a
|
|
672
|
+
black screen as a calm one, but it cannot fix it: restarting the device is
|
|
673
|
+
the cure that always works, and it usually recovers on its own.
|
|
649
674
|
|
|
650
675
|
## Roadmap
|
|
651
676
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simframe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"mcpName": "io.github.lvlrSajjad/simframe",
|
|
5
5
|
"description": "Always-warm iOS Simulator and Android emulator frames: agents read the screen in ~20ms instead of waiting on screenshots. MCP server + CLI.",
|
|
6
6
|
"keywords": [
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// No third-party app identifiers in this repository. Ever, from anyone.
|
|
3
|
+
//
|
|
4
|
+
// simframe is a general-purpose tool: you install it and Claude Code drives
|
|
5
|
+
// *your* app on the simulator. It has no relationship with any particular app,
|
|
6
|
+
// so no particular app's bundle id belongs in it — not in the source, not in
|
|
7
|
+
// the docs, and not in a secret either. A denylist of specific strings would
|
|
8
|
+
// assume there is one app to protect, which is the wrong shape for this.
|
|
9
|
+
//
|
|
10
|
+
// So the rule is a pattern, not a list, and it needs no configuration at all.
|
|
11
|
+
// Anything shaped like a reverse-DNS bundle id is flagged unless it is one of:
|
|
12
|
+
//
|
|
13
|
+
// * a platform's own com.apple.*, com.android.*, com.google.*
|
|
14
|
+
// * a documentation placeholder com.example.*, com.acme.*, com.mycompany.*
|
|
15
|
+
// * this project's own identifiers
|
|
16
|
+
//
|
|
17
|
+
// That works on a fresh clone, on a fork, and in a pull request from a stranger,
|
|
18
|
+
// which a secret does not. An optional `.private-strings` file (gitignored) or
|
|
19
|
+
// $SIMFRAME_PRIVATE_STRINGS still adds extra patterns for anyone who wants them,
|
|
20
|
+
// but nothing depends on either existing.
|
|
21
|
+
//
|
|
22
|
+
// How this got written: a 282-line field-notes file about a real third-party app
|
|
23
|
+
// was committed here by `git add -A` and pushed, an hour after the first version
|
|
24
|
+
// of this script was written to prevent exactly that. It could not fire, because
|
|
25
|
+
// it was waiting for a denylist nobody had supplied. A guard with a
|
|
26
|
+
// precondition is a guard that is off.
|
|
27
|
+
//
|
|
28
|
+
// Nothing here ever prints a match. It prints the file and the line number, so
|
|
29
|
+
// the output of a failed run is safe to paste into an issue, a CI log, or a
|
|
30
|
+
// conversation with an agent — which is where the last one would have gone.
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { execFileSync } from 'node:child_process';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
|
|
36
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
37
|
+
const LIST_FILE = path.join(ROOT, '.private-strings');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Bundle-id-shaped strings, and the ones that are fine.
|
|
41
|
+
*
|
|
42
|
+
* The first segment is restricted to real reverse-DNS prefixes, which is what
|
|
43
|
+
* keeps ordinary property chains out: `res.state.seq`, `registry.paths.dir` and
|
|
44
|
+
* `import.meta.url` all look exactly like bundle ids until you require the head
|
|
45
|
+
* to be a TLD.
|
|
46
|
+
*/
|
|
47
|
+
const BUNDLE = /\b(?:com|io|org|net|dev|co|app|me|xyz|uk|de|fr|jp|nl|se|ca|au)\.[A-Za-z][A-Za-z0-9_-]{1,30}(?:\.[A-Za-z][A-Za-z0-9_-]{0,30}){1,3}\b/g;
|
|
48
|
+
|
|
49
|
+
/** Platform-owned, placeholder, or ours. Anything else is somebody's app. */
|
|
50
|
+
export const ALLOWED = [
|
|
51
|
+
/^com\.apple\./i,
|
|
52
|
+
/^com\.android\./i,
|
|
53
|
+
/^com\.google\./i,
|
|
54
|
+
/^org\.swift\./i,
|
|
55
|
+
/^org\.json\./i,
|
|
56
|
+
/^com\.facebook\./i, // idb, a reference implementation named in the docs
|
|
57
|
+
/^com\.example\./i,
|
|
58
|
+
/^com\.acme\./i,
|
|
59
|
+
/^com\.mycompany\./i,
|
|
60
|
+
/^com\.yourcompany\./i,
|
|
61
|
+
/^io\.github\./i,
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
export function isAllowedIdentifier(id) {
|
|
65
|
+
return ALLOWED.some((re) => re.test(id));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Extra patterns, for anyone who wants them. Nothing depends on this existing. */
|
|
69
|
+
export function patternsFrom({ env, file } = {}) {
|
|
70
|
+
const raw = [
|
|
71
|
+
...String(env ?? '').split(/[\n,]/),
|
|
72
|
+
...String(file ?? '').split(/\n/),
|
|
73
|
+
];
|
|
74
|
+
return [...new Set(
|
|
75
|
+
raw
|
|
76
|
+
.map((s) => s.trim())
|
|
77
|
+
.filter((s) => s && !s.startsWith('#'))
|
|
78
|
+
// One character would match everything, which is a check that only ever
|
|
79
|
+
// fails and therefore only ever gets disabled.
|
|
80
|
+
.filter((s) => s.length >= 3)
|
|
81
|
+
.map((s) => s.toLowerCase()),
|
|
82
|
+
)];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Which lines of `text` are a problem. Line numbers only, never matches. */
|
|
86
|
+
export function offendingLines(text, patterns = []) {
|
|
87
|
+
const hits = [];
|
|
88
|
+
const lines = String(text).split('\n');
|
|
89
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
90
|
+
const lower = lines[i].toLowerCase();
|
|
91
|
+
const why = [];
|
|
92
|
+
const extra = patterns.filter((p) => lower.includes(p)).length;
|
|
93
|
+
if (extra) why.push(`${extra} denied pattern(s)`);
|
|
94
|
+
const ids = (lines[i].match(BUNDLE) ?? []).filter((id) => !isAllowedIdentifier(id));
|
|
95
|
+
// Reported as a count and a shape, never as the identifier: knowing which
|
|
96
|
+
// app leaked is worth less to a bug report than not restating it.
|
|
97
|
+
if (ids.length) why.push(`${ids.length} third-party bundle id(s)`);
|
|
98
|
+
if (why.length) hits.push({ line: i + 1, why: why.join(', ') });
|
|
99
|
+
}
|
|
100
|
+
return hits;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function trackedFiles() {
|
|
104
|
+
return execFileSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8' })
|
|
105
|
+
.split('\0')
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function main() {
|
|
110
|
+
const patterns = patternsFrom({
|
|
111
|
+
env: process.env.SIMFRAME_PRIVATE_STRINGS,
|
|
112
|
+
file: fs.existsSync(LIST_FILE) ? fs.readFileSync(LIST_FILE, 'utf8') : '',
|
|
113
|
+
});
|
|
114
|
+
const files = trackedFiles();
|
|
115
|
+
let failed = 0;
|
|
116
|
+
for (const rel of files) {
|
|
117
|
+
const full = path.join(ROOT, rel);
|
|
118
|
+
let text;
|
|
119
|
+
try {
|
|
120
|
+
const stat = fs.statSync(full);
|
|
121
|
+
if (!stat.isFile() || stat.size > 4_000_000) continue;
|
|
122
|
+
text = fs.readFileSync(full, 'utf8');
|
|
123
|
+
} catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// A NUL byte means this is not text, and a substring hit in it is noise.
|
|
127
|
+
if (text.includes('\0')) continue;
|
|
128
|
+
for (const hit of offendingLines(text, patterns)) {
|
|
129
|
+
console.error(`check-private: ${rel}:${hit.line} — ${hit.why}`);
|
|
130
|
+
failed += 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
console.log(`check-private: ${files.length} tracked file(s); no third-party bundle ids`
|
|
134
|
+
+ (patterns.length ? `, plus ${patterns.length} local pattern(s)` : '')
|
|
135
|
+
+ ` — ${failed} line(s) flagged`);
|
|
136
|
+
if (failed) {
|
|
137
|
+
console.error('check-private: nothing above prints the match itself. '
|
|
138
|
+
+ 'A third-party app identifier does not belong in a general-purpose tool.');
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (import.meta.url === `file://${process.argv[1]}`) main();
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The perception eval harness. Deferred since Phase 5; four things wait on it.
|
|
3
|
+
//
|
|
4
|
+
// What it is, and the design decision behind it. The obvious harness replays
|
|
5
|
+
// stored frames through the perception path and diffs the element lists, which
|
|
6
|
+
// is what Phase 13 step 5 describes. That harness cannot exist: the element
|
|
7
|
+
// list is the accessibility tree fused with OCR, the tree is not in the frame,
|
|
8
|
+
// and OCR runs in the daemon against a live framebuffer. A frame on disk is
|
|
9
|
+
// half the input.
|
|
10
|
+
//
|
|
11
|
+
// So the split is different and, for what actually needs gating, better. The
|
|
12
|
+
// machine records the *input* — the fused element list for a screen, exactly as
|
|
13
|
+
// perception produced it. A person authors the *expected output*. Everything
|
|
14
|
+
// downstream of the element list is a pure function, so the check runs offline,
|
|
15
|
+
// deterministically, with no simulator and no daemon:
|
|
16
|
+
//
|
|
17
|
+
// resolution matching.resolve(targets, query) — which element a query picks
|
|
18
|
+
// identity fingerprint.tokens(targets) — which screen this is
|
|
19
|
+
// change analyze.signatureDiff(a, b) — whether a frame moved
|
|
20
|
+
//
|
|
21
|
+
// Those three are precisely the thresholds every blocked item wants to change.
|
|
22
|
+
// What it does *not* cover is whether perception found the elements at all,
|
|
23
|
+
// which is inherently live and stays with eval-fingerprint.mjs and the
|
|
24
|
+
// integration job. Said plainly here rather than implied, because a harness
|
|
25
|
+
// that is trusted for more than it measures is worse than no harness.
|
|
26
|
+
//
|
|
27
|
+
// Two rules from this repo:
|
|
28
|
+
// - The gate is an exit code. Nothing is verified through a pipe: this
|
|
29
|
+
// project has already shipped a check that could not fail because
|
|
30
|
+
// `node script.mjs | tail` reports tail's status.
|
|
31
|
+
// - A fixture with no authored expectations is reported as unauthored, never
|
|
32
|
+
// counted as a pass. An empty test suite is green.
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import * as fingerprint from '../src/fingerprint.js';
|
|
37
|
+
import * as matching from '../src/matching.js';
|
|
38
|
+
import * as analyze from '../src/analyze.js';
|
|
39
|
+
|
|
40
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
const DIR = path.join(ROOT, 'test', 'perception', 'screens');
|
|
42
|
+
const arg = (n, d = null) => {
|
|
43
|
+
const hit = process.argv.find((a) => a.startsWith(`--${n}=`));
|
|
44
|
+
return hit ? hit.slice(n.length + 3) : d;
|
|
45
|
+
};
|
|
46
|
+
const has = (n) => process.argv.includes(`--${n}`);
|
|
47
|
+
|
|
48
|
+
export function fixtures(dir = DIR, only = null) {
|
|
49
|
+
if (!fs.existsSync(dir)) return [];
|
|
50
|
+
return fs.readdirSync(dir)
|
|
51
|
+
.filter((f) => f.endsWith('.json'))
|
|
52
|
+
.filter((f) => (only ? f.includes(only) : true))
|
|
53
|
+
.sort()
|
|
54
|
+
.map((f) => ({ file: f, ...JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check one screen's authored expectations against the pure layers.
|
|
59
|
+
*
|
|
60
|
+
* Returns findings rather than printing, so the same function backs the CLI and
|
|
61
|
+
* the unit tests — and so a finding can be counted without being formatted.
|
|
62
|
+
*/
|
|
63
|
+
export function checkScreen(fx) {
|
|
64
|
+
const findings = [];
|
|
65
|
+
const screen = fx.points ?? null;
|
|
66
|
+
const targets = fx.targets ?? [];
|
|
67
|
+
const expect = fx.expect ?? {};
|
|
68
|
+
const authored = (expect.resolutions?.length ?? 0)
|
|
69
|
+
+ (expect.ambiguous?.length ?? 0)
|
|
70
|
+
+ (expect.none?.length ?? 0)
|
|
71
|
+
+ (fx.frame_pairs?.length ?? 0)
|
|
72
|
+
+ (expect.identity ? 1 : 0);
|
|
73
|
+
|
|
74
|
+
for (const r of expect.resolutions ?? []) {
|
|
75
|
+
const out = matching.resolve(targets, r.query, { screen });
|
|
76
|
+
if (out.status !== 'ok') {
|
|
77
|
+
findings.push({
|
|
78
|
+
kind: 'resolution',
|
|
79
|
+
query: r.query,
|
|
80
|
+
want: r.label,
|
|
81
|
+
got: out.status === 'ambiguous'
|
|
82
|
+
? `ambiguous between ${out.alternatives.map((a) => a.label).join(', ')}`
|
|
83
|
+
: 'nothing resolved',
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const got = out.target.label ?? '(icon-only)';
|
|
88
|
+
// Compared on the label because that is what the author can see and mean.
|
|
89
|
+
// Coordinates would make a fixture break every time a row moved a point.
|
|
90
|
+
if (got !== r.label) {
|
|
91
|
+
findings.push({ kind: 'resolution', query: r.query, want: r.label, got: `"${got}" at ${out.target.x},${out.target.y}` });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The other half of the contract, and the half with the wrong-tap risk in it:
|
|
96
|
+
// "when two things answer equally well it says so rather than guessing".
|
|
97
|
+
for (const q of expect.ambiguous ?? []) {
|
|
98
|
+
const out = matching.resolve(targets, typeof q === 'string' ? q : q.query, { screen });
|
|
99
|
+
if (out.status !== 'ambiguous') {
|
|
100
|
+
findings.push({
|
|
101
|
+
kind: 'should-ask',
|
|
102
|
+
query: typeof q === 'string' ? q : q.query,
|
|
103
|
+
want: 'ambiguous',
|
|
104
|
+
got: out.status === 'ok' ? `picked "${out.target.label}" at score ${out.score}` : 'nothing resolved',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const q of expect.none ?? []) {
|
|
110
|
+
const out = matching.resolve(targets, q, { screen });
|
|
111
|
+
if (out.status !== 'none') {
|
|
112
|
+
findings.push({
|
|
113
|
+
kind: 'should-find-nothing',
|
|
114
|
+
query: q,
|
|
115
|
+
want: 'none',
|
|
116
|
+
got: out.status === 'ok' ? `picked "${out.target.label}"` : 'ambiguous',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Identity drift. A token-rule change that silently alters a screen's
|
|
122
|
+
// structural hash discards every stored map and graph for it, and the failure
|
|
123
|
+
// is invisible — an old hash is a well-formed hash that matches nothing.
|
|
124
|
+
if (expect.identity && screen) {
|
|
125
|
+
const now = fingerprint.fingerprint(targets, screen);
|
|
126
|
+
if (now.hash !== expect.identity.hash) {
|
|
127
|
+
const similarity = fingerprint.similarity(now.tokens, expect.identity.tokens ?? []);
|
|
128
|
+
findings.push({
|
|
129
|
+
kind: 'identity',
|
|
130
|
+
query: '(structural hash)',
|
|
131
|
+
want: `${expect.identity.hash?.slice(0, 12)} (${expect.identity.tokens?.length ?? 0} tokens)`,
|
|
132
|
+
got: `${now.hash?.slice(0, 12)} (${now.tokens.length} tokens), similarity ${similarity.toFixed(2)}`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Frame pairs, for the change detector. `changed: true` means an action that
|
|
138
|
+
// a person would call visible — a switch flipping, a radio dot moving.
|
|
139
|
+
for (const pair of fx.frame_pairs ?? []) {
|
|
140
|
+
const a = analyze.hexToSignature(pair.after);
|
|
141
|
+
const b = analyze.hexToSignature(pair.before);
|
|
142
|
+
// Two different questions about the same pair of frames: has the *screen*
|
|
143
|
+
// changed (the mean, which drives stillness) and has a *control* changed
|
|
144
|
+
// (the largest single region, which drives the verdict). A switch flip
|
|
145
|
+
// answers no to the first and yes to the second, which is the whole reason
|
|
146
|
+
// both exist.
|
|
147
|
+
const diff = pair.per_cell ? analyze.maxCellDelta(a, b) : analyze.signatureDiff(a, b);
|
|
148
|
+
const seen = diff > (pair.threshold ?? 0.004);
|
|
149
|
+
if (seen !== Boolean(pair.changed)) {
|
|
150
|
+
findings.push({
|
|
151
|
+
kind: 'change',
|
|
152
|
+
query: pair.note ?? '(frame pair)',
|
|
153
|
+
want: pair.changed ? 'a visible change' : 'no change',
|
|
154
|
+
got: `${pair.per_cell ? 'max cell' : 'mean'} ${diff.toFixed(5)} against a threshold of ${pair.threshold ?? 0.004}`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { authored, findings };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function record() {
|
|
163
|
+
const api = await import('../src/index.js');
|
|
164
|
+
const device = arg('device');
|
|
165
|
+
const name = arg('name');
|
|
166
|
+
if (!name) throw new Error('--record needs --name=<app>/<screen>');
|
|
167
|
+
const id = await api.screenIdentity(device, { fresh: true, confirmNovel: false });
|
|
168
|
+
const entry = id.entry ?? {};
|
|
169
|
+
const out = {
|
|
170
|
+
app: name.split('/')[0],
|
|
171
|
+
screen: name.split('/').slice(1).join('/') || name,
|
|
172
|
+
note: arg('note') ?? null,
|
|
173
|
+
recorded_at: new Date().toISOString(),
|
|
174
|
+
points: id.points,
|
|
175
|
+
// The recorded input. Trimmed to what the pure layers read, so a fixture
|
|
176
|
+
// is reviewable by a person rather than a wall of machine state.
|
|
177
|
+
targets: (entry.targets ?? []).map((t) => ({
|
|
178
|
+
label: t.label ?? null,
|
|
179
|
+
value: t.value ?? null,
|
|
180
|
+
type: t.type ?? null,
|
|
181
|
+
x: t.x, y: t.y,
|
|
182
|
+
frame: t.frame ?? null,
|
|
183
|
+
region: t.region ?? null,
|
|
184
|
+
source: t.source ?? null,
|
|
185
|
+
aliases: t.aliases ?? undefined,
|
|
186
|
+
navSlot: t.navSlot ?? undefined,
|
|
187
|
+
enabled: t.enabled ?? undefined,
|
|
188
|
+
selected: t.selected ?? undefined,
|
|
189
|
+
})),
|
|
190
|
+
expect: {
|
|
191
|
+
identity: { hash: entry.structuralHash, tokens: entry.structuralTokens ?? [] },
|
|
192
|
+
// Authored by hand. The machine records what perception saw; a person
|
|
193
|
+
// says what it should mean. Deriving these from current behaviour would
|
|
194
|
+
// bake today's bugs in as the specification.
|
|
195
|
+
resolutions: [],
|
|
196
|
+
ambiguous: [],
|
|
197
|
+
none: [],
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
201
|
+
const file = path.join(DIR, `${name.replace(/\//g, '__')}.json`);
|
|
202
|
+
fs.writeFileSync(file, `${JSON.stringify(out, null, 2)}\n`);
|
|
203
|
+
console.log(`recorded ${out.targets.length} element(s) from ${name} -> ${path.relative(ROOT, file)}`);
|
|
204
|
+
console.log(' now author expect.resolutions / expect.ambiguous / expect.none by hand');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function check() {
|
|
208
|
+
const all = fixtures(DIR, arg('only'));
|
|
209
|
+
if (!all.length) {
|
|
210
|
+
console.error('check-perception: no fixtures. Record some with --record --name=<app>/<screen>');
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
let failed = 0;
|
|
215
|
+
let unauthored = 0;
|
|
216
|
+
let checks = 0;
|
|
217
|
+
const apps = new Set();
|
|
218
|
+
for (const fx of all) {
|
|
219
|
+
apps.add(fx.app);
|
|
220
|
+
const { authored, findings } = checkScreen(fx);
|
|
221
|
+
checks += authored;
|
|
222
|
+
if (!authored) {
|
|
223
|
+
unauthored += 1;
|
|
224
|
+
console.log(` ?? ${fx.app}/${fx.screen} recorded, no expectations authored`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (!findings.length) {
|
|
228
|
+
console.log(` ok ${fx.app}/${fx.screen} ${authored} expectation(s)`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
failed += findings.length;
|
|
232
|
+
console.log(`FAIL ${fx.app}/${fx.screen}`);
|
|
233
|
+
for (const f of findings) {
|
|
234
|
+
console.log(` ${f.kind}: ${f.query}\n want ${f.want}\n got ${f.got}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
console.log(`\n${all.length} screen(s) across ${apps.size} app(s), ${checks} expectation(s), ${failed} failure(s)`
|
|
238
|
+
+ (unauthored ? `, ${unauthored} unauthored` : ''));
|
|
239
|
+
// An unauthored fixture is not a pass. A suite that counts recordings as
|
|
240
|
+
// successes is a suite that goes green by adding files.
|
|
241
|
+
if (failed || unauthored) process.exitCode = 1;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
245
|
+
if (has('record')) await record();
|
|
246
|
+
else if (has('list')) for (const f of fixtures()) console.log(`${f.app}/${f.screen} ${f.targets?.length ?? 0} elements`);
|
|
247
|
+
else check();
|
|
248
|
+
}
|