simframe 0.17.0 → 0.19.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 +126 -1058
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +12 -6
- package/package.json +1 -1
- package/scripts/article-md.mjs +111 -45
- package/scripts/bench-hpi.mjs +53 -1
- package/scripts/ci-device-guard.mjs +27 -0
- package/scripts/ci-memory.mjs +33 -6
- package/scripts/demo-gif/README.md +36 -0
- package/scripts/demo-gif/compose.swift +106 -0
- package/scripts/demo-gif/events.example.json +74 -0
- package/scripts/demo-gif/flow.json +6 -0
- package/scripts/device-state.mjs +5 -62
- package/scripts/smithery/icon.png +0 -0
- package/scripts/smithery-bundle.mjs +77 -0
- package/src/actions.js +309 -32
- package/src/cli.js +111 -29
- package/src/device-state.js +101 -0
- package/src/index.js +139 -11
- package/src/metrics.js +144 -3
- package/src/navigate.js +10 -0
- package/src/platform/cdp.js +242 -0
- package/src/platform/index.js +28 -2
- package/src/store.js +39 -0
- package/src/wedge.js +370 -0
|
@@ -45,13 +45,19 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
|
|
|
45
45
|
private static func loadFrameworks() throws {
|
|
46
46
|
guard !loaded else { return }
|
|
47
47
|
let developerDir = Self.developerDir()
|
|
48
|
-
let candidates = [
|
|
49
|
-
("CoreSimulator", "/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator"),
|
|
50
|
-
("SimulatorKit",
|
|
48
|
+
let candidates: [(String, [String])] = [
|
|
49
|
+
("CoreSimulator", ["/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator"]),
|
|
50
|
+
("SimulatorKit", [
|
|
51
|
+
"\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
|
|
52
|
+
// Xcode moved this out of Contents/Developer into the top-level
|
|
53
|
+
// Contents/SharedFrameworks at some point around Xcode 16-27;
|
|
54
|
+
// keep the old path first since it's still what older Xcodes use.
|
|
55
|
+
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
|
|
56
|
+
]),
|
|
51
57
|
]
|
|
52
|
-
for (name,
|
|
53
|
-
guard FileManager.default.fileExists(atPath:
|
|
54
|
-
throw PrivateAPIError.frameworksUnavailable("\(name) not found at \(
|
|
58
|
+
for (name, paths) in candidates {
|
|
59
|
+
guard let path = paths.first(where: { FileManager.default.fileExists(atPath: $0) }) else {
|
|
60
|
+
throw PrivateAPIError.frameworksUnavailable("\(name) not found at \(paths.joined(separator: " or "))")
|
|
55
61
|
}
|
|
56
62
|
guard let handle = dlopen(path, RTLD_NOW) else {
|
|
57
63
|
let reason = String(cString: dlerror())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simframe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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": [
|
package/scripts/article-md.mjs
CHANGED
|
@@ -21,9 +21,17 @@
|
|
|
21
21
|
// anything it does not recognise rather than dropping it — a converter that
|
|
22
22
|
// silently skips an unfamiliar tag produces a document that looks complete and
|
|
23
23
|
// is not, which is the failure mode this whole file exists to prevent.
|
|
24
|
+
//
|
|
25
|
+
// That promise had a hole in it for as long as it existed, and the hole was
|
|
26
|
+
// not an unfamiliar tag but the space between two familiar ones. The scanner
|
|
27
|
+
// matched a list of constructs and stepped over everything in between without
|
|
28
|
+
// looking, so the colophon's body — a text node the page puts straight inside
|
|
29
|
+
// its container, with no `<p>` around it — was not unhandled, it was unseen,
|
|
30
|
+
// and the document ended on a bare **On the numbers** label. Refusing what you
|
|
31
|
+
// do not recognise is only half of it; you have to look everywhere first.
|
|
24
32
|
import fs from 'node:fs';
|
|
25
33
|
import path from 'node:path';
|
|
26
|
-
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
27
35
|
|
|
28
36
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
37
|
const SOURCE = path.join(ROOT, 'docs', 'agents-shouldnt-blink.html');
|
|
@@ -81,12 +89,36 @@ function table(html) {
|
|
|
81
89
|
];
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Tags that hold blocks without being one. The scanner walks through these; a
|
|
94
|
+
* tag in a gap that is *not* one of them is content, and `inline` throws on it.
|
|
95
|
+
*/
|
|
96
|
+
const CONTAINER = /<\/?(?:div|ul|ol|section|header|figure)\b[^>]*>/g;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What the scanner stepped over between two blocks. Containers are expected.
|
|
100
|
+
* Anything left is a text node the page put straight inside a container rather
|
|
101
|
+
* than in a `<p>` — the colophon's body is one — and it is content like any
|
|
102
|
+
* other. Nothing looked in the gaps before, so every generated copy of this
|
|
103
|
+
* document ended on a dangling **On the numbers** label with its paragraph
|
|
104
|
+
* gone: exactly the silent-drop failure the rest of this file refuses.
|
|
105
|
+
*/
|
|
106
|
+
function gap(html) {
|
|
107
|
+
return html
|
|
108
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
109
|
+
.split(CONTAINER)
|
|
110
|
+
.map((piece) => piece.trim())
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.map((text) => ({ kind: 'p', text: inline(text) }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Everything inside one `<section>` (or the `<header>`), in order. */
|
|
85
116
|
function blocks(html) {
|
|
86
117
|
const out = [];
|
|
87
118
|
// One pass over the constructs the article actually uses. `lastIndex`
|
|
88
119
|
// walking rather than nested parsing, because the document is flat inside a
|
|
89
|
-
// section and a real parser here would be a dependency.
|
|
120
|
+
// section and a real parser here would be a dependency. What the pass does
|
|
121
|
+
// *not* match is handed to `gap` rather than skipped.
|
|
90
122
|
const re = new RegExp([
|
|
91
123
|
'<h([123])>([\\s\\S]*?)<\\/h\\1>',
|
|
92
124
|
'<p class="sec-label">([\\s\\S]*?)<\\/p>',
|
|
@@ -96,10 +128,15 @@ function blocks(html) {
|
|
|
96
128
|
'<table>([\\s\\S]*?)<\\/table>',
|
|
97
129
|
'<pre>([\\s\\S]*?)<\\/pre>',
|
|
98
130
|
'<li>([\\s\\S]*?)<\\/li>',
|
|
131
|
+
'<figcaption>([\\s\\S]*?)<\\/figcaption>',
|
|
132
|
+
'<svg\\b([^>]*)>[\\s\\S]*?<\\/svg>',
|
|
99
133
|
].join('|'), 'g');
|
|
100
134
|
let m;
|
|
135
|
+
let cursor = 0;
|
|
101
136
|
while ((m = re.exec(html)) !== null) {
|
|
102
|
-
|
|
137
|
+
out.push(...gap(html.slice(cursor, m.index)));
|
|
138
|
+
cursor = re.lastIndex;
|
|
139
|
+
const [, level, heading, sec, panel, amend, para, tbl, pre, li, caption, svg] = m;
|
|
103
140
|
if (heading != null) out.push({ kind: 'h', level: Number(level), text: inline(heading) });
|
|
104
141
|
else if (sec != null) out.push({ kind: 'kicker', text: inline(sec) });
|
|
105
142
|
else if (panel != null) out.push({ kind: 'label', text: inline(panel) });
|
|
@@ -108,17 +145,65 @@ function blocks(html) {
|
|
|
108
145
|
else if (tbl != null) out.push({ kind: 'table', lines: table(tbl) });
|
|
109
146
|
else if (pre != null) out.push({ kind: 'pre', text: decode(stripTagsPreserving(pre)) });
|
|
110
147
|
else if (li != null) out.push({ kind: 'li', text: inline(li) });
|
|
148
|
+
else if (caption != null) out.push({ kind: 'p', text: inline(caption) });
|
|
149
|
+
// A diagram cannot be projected into Markdown, but its `aria-label` is
|
|
150
|
+
// already the prose a screen reader gets, so that is what a reader of the
|
|
151
|
+
// Markdown gets too. An undescribed diagram is content this cannot carry,
|
|
152
|
+
// so it throws rather than quietly becoming nothing.
|
|
153
|
+
else if (svg != null) {
|
|
154
|
+
const label = svg.match(/aria-label="([^"]*)"/);
|
|
155
|
+
if (!label) throw new Error('<svg> with no aria-label — nothing to project');
|
|
156
|
+
out.push({ kind: 'figure', text: inline(label[1]) });
|
|
157
|
+
}
|
|
111
158
|
}
|
|
159
|
+
out.push(...gap(html.slice(cursor)));
|
|
112
160
|
return out;
|
|
113
161
|
}
|
|
114
162
|
|
|
115
163
|
/** `<pre>` keeps its newlines; only tags come out. */
|
|
116
164
|
const stripTagsPreserving = (s) => s.replace(/<[^>]+>/g, '').replace(/^\n/, '').replace(/\s+$/, '');
|
|
117
165
|
|
|
166
|
+
/** One section's or the header's blocks, appended to the document. */
|
|
167
|
+
function emit(lines, bs) {
|
|
168
|
+
let pendingKicker = null;
|
|
169
|
+
let inList = false;
|
|
170
|
+
for (const b of bs) {
|
|
171
|
+
if (b.kind !== 'li' && inList) { lines.push(''); inList = false; }
|
|
172
|
+
switch (b.kind) {
|
|
173
|
+
// The kicker precedes its heading on the page and reads as a label for
|
|
174
|
+
// it, so it is held until the heading arrives rather than emitted where
|
|
175
|
+
// it was found.
|
|
176
|
+
case 'kicker': pendingKicker = b.text; break;
|
|
177
|
+
case 'h':
|
|
178
|
+
if (pendingKicker) { lines.push(`*${pendingKicker}*`, ''); pendingKicker = null; }
|
|
179
|
+
// `<h1>` is the title, already emitted above, so the page's `<h2>`
|
|
180
|
+
// sections are the document's second level and not its third.
|
|
181
|
+
lines.push(`${'#'.repeat(b.level)} ${b.text}`, '');
|
|
182
|
+
break;
|
|
183
|
+
case 'label': lines.push(`**${b.text}**`, ''); break;
|
|
184
|
+
case 'p': lines.push(b.text, ''); break;
|
|
185
|
+
case 'figure': lines.push(`*Figure — ${b.text}*`, ''); break;
|
|
186
|
+
case 'li': lines.push(`- ${b.text}`); inList = true; break;
|
|
187
|
+
case 'table': lines.push(...b.lines, ''); break;
|
|
188
|
+
case 'pre': lines.push('```', b.text, '```', ''); break;
|
|
189
|
+
default: throw new Error(`unhandled block ${b.kind}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (inList) lines.push('');
|
|
193
|
+
}
|
|
194
|
+
|
|
118
195
|
export function render(html) {
|
|
119
196
|
const title = stripTags(html.match(/<h1>([\s\S]*?)<\/h1>/)[1]);
|
|
120
197
|
const kicker = stripTags(html.match(/<p class="kicker">([\s\S]*?)<\/p>/)[1]);
|
|
121
|
-
|
|
198
|
+
// The rest of the header: the deck, the figure, the provenance line. This
|
|
199
|
+
// used to look for `<p class="standfirst">`, a class the page does not have,
|
|
200
|
+
// so the match was always null and the branch that emitted it never ran —
|
|
201
|
+
// the same silent drop as the colophon, arrived at from the other end. The
|
|
202
|
+
// header is walked with the section machinery now, minus the two blocks
|
|
203
|
+
// emitted by hand above.
|
|
204
|
+
const header = (html.match(/<header>([\s\S]*?)<\/header>/)?.[1] ?? '')
|
|
205
|
+
.replace(/<h1>[\s\S]*?<\/h1>/, '')
|
|
206
|
+
.replace(/<p class="kicker">[\s\S]*?<\/p>/, '');
|
|
122
207
|
|
|
123
208
|
const lines = [
|
|
124
209
|
`# ${title}`,
|
|
@@ -134,52 +219,33 @@ export function render(html) {
|
|
|
134
219
|
'> in [`EXPERIMENTS.md`](EXPERIMENTS.md).',
|
|
135
220
|
'',
|
|
136
221
|
];
|
|
137
|
-
|
|
222
|
+
emit(lines, blocks(header));
|
|
138
223
|
lines.push('---', '');
|
|
139
224
|
|
|
140
|
-
for (const section of html.match(/<section>[\s\S]*?<\/section>/g) ?? [])
|
|
141
|
-
let pendingKicker = null;
|
|
142
|
-
let inList = false;
|
|
143
|
-
for (const b of blocks(section)) {
|
|
144
|
-
if (b.kind !== 'li' && inList) { lines.push(''); inList = false; }
|
|
145
|
-
switch (b.kind) {
|
|
146
|
-
// The kicker precedes its heading on the page and reads as a label for
|
|
147
|
-
// it, so it is held until the heading arrives rather than emitted where
|
|
148
|
-
// it was found.
|
|
149
|
-
case 'kicker': pendingKicker = b.text; break;
|
|
150
|
-
case 'h':
|
|
151
|
-
if (pendingKicker) { lines.push(`*${pendingKicker}*`, ''); pendingKicker = null; }
|
|
152
|
-
// `<h1>` is the title, already emitted above, so the page's `<h2>`
|
|
153
|
-
// sections are the document's second level and not its third.
|
|
154
|
-
lines.push(`${'#'.repeat(b.level)} ${b.text}`, '');
|
|
155
|
-
break;
|
|
156
|
-
case 'label': lines.push(`**${b.text}**`, ''); break;
|
|
157
|
-
case 'p': lines.push(b.text, ''); break;
|
|
158
|
-
case 'li': lines.push(`- ${b.text}`); inList = true; break;
|
|
159
|
-
case 'table': lines.push(...b.lines, ''); break;
|
|
160
|
-
case 'pre': lines.push('```', b.text, '```', ''); break;
|
|
161
|
-
default: throw new Error(`unhandled block ${b.kind}`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
if (inList) lines.push('');
|
|
165
|
-
}
|
|
225
|
+
for (const section of html.match(/<section>[\s\S]*?<\/section>/g) ?? []) emit(lines, blocks(section));
|
|
166
226
|
|
|
167
227
|
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
|
|
168
228
|
}
|
|
169
229
|
|
|
170
|
-
|
|
171
|
-
|
|
230
|
+
// Only as a command. This used to run on import, which meant the test that
|
|
231
|
+
// asserts the page and the Markdown are one document regenerated the Markdown
|
|
232
|
+
// before reading it back and compared the output to itself. It could not fail,
|
|
233
|
+
// and it did not — the colophon's missing paragraph sat under a green suite
|
|
234
|
+
// for as long as it existed. `--check` in CI was the only real gate.
|
|
235
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
236
|
+
const md = render(fs.readFileSync(SOURCE, 'utf8'));
|
|
172
237
|
|
|
173
|
-
if (process.argv.includes('--check')) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
238
|
+
if (process.argv.includes('--check')) {
|
|
239
|
+
const current = fs.existsSync(TARGET) ? fs.readFileSync(TARGET, 'utf8') : '';
|
|
240
|
+
if (current === md) {
|
|
241
|
+
console.log(`docs/ARTICLE.md is in step with the page (${md.split('\n').length} lines)`);
|
|
242
|
+
process.exit(0);
|
|
243
|
+
}
|
|
244
|
+
console.error('docs/ARTICLE.md is out of date with docs/agents-shouldnt-blink.html.');
|
|
245
|
+
console.error('Run: node scripts/article-md.mjs');
|
|
246
|
+
process.exit(1);
|
|
178
247
|
}
|
|
179
|
-
console.error('docs/ARTICLE.md is out of date with docs/agents-shouldnt-blink.html.');
|
|
180
|
-
console.error('Run: node scripts/article-md.mjs');
|
|
181
|
-
process.exit(1);
|
|
182
|
-
}
|
|
183
248
|
|
|
184
|
-
fs.writeFileSync(TARGET, md);
|
|
185
|
-
console.log(`wrote docs/ARTICLE.md — ${md.split('\n').length} lines from ${SOURCE.split('/').pop()}`);
|
|
249
|
+
fs.writeFileSync(TARGET, md);
|
|
250
|
+
console.log(`wrote docs/ARTICLE.md — ${md.split('\n').length} lines from ${SOURCE.split('/').pop()}`);
|
|
251
|
+
}
|
package/scripts/bench-hpi.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { runScript } from '../src/actions.js';
|
|
|
21
21
|
import * as api from '../src/index.js';
|
|
22
22
|
import * as baseline from '../src/baseline.js';
|
|
23
23
|
import * as metrics from '../src/metrics.js';
|
|
24
|
+
import * as wedge from '../src/wedge.js';
|
|
24
25
|
|
|
25
26
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
27
|
const arg = (name, fallback = null) => {
|
|
@@ -55,8 +56,22 @@ const passes = Math.max(1, Number(arg('passes', gate ? '3' : '1')));
|
|
|
55
56
|
* asked to do it degrades and then stops rendering. The suite paces itself so
|
|
56
57
|
* the numbers describe simframe rather than the simulator's tolerance for
|
|
57
58
|
* being hammered.
|
|
59
|
+
*
|
|
60
|
+
* **1500 was not enough, and that is measured rather than argued.** Same flow,
|
|
61
|
+
* same device, same afternoon, varying only this number: at 1500 ms the device
|
|
62
|
+
* stopped being readable after 4, 4 and 5 runs across three separate attempts;
|
|
63
|
+
* at 4000-5000 ms a full 8-run pass finished with `diagnose` still saying
|
|
64
|
+
* `healthy`. That is a large part of item 173 — "the bench suite wedges the
|
|
65
|
+
* device it measures" — and it means the suite has never once completed a full
|
|
66
|
+
* pass on a hosted runner.
|
|
67
|
+
*
|
|
68
|
+
* Raising it is a measurement change, not a tuning tweak: **every HPI number in
|
|
69
|
+
* this repo recorded before 2026-09-18 was taken at 1500**, including the
|
|
70
|
+
* committed reference, so numbers across that line are not comparable. The
|
|
71
|
+
* reference was re-recorded at the new default for exactly that reason, and
|
|
72
|
+
* `docs/BENCHMARKS.md` says so.
|
|
58
73
|
*/
|
|
59
|
-
const cooldownMs = Math.max(0, Number(arg('cooldown', '
|
|
74
|
+
const cooldownMs = Math.max(0, Number(arg('cooldown', '4000')));
|
|
60
75
|
|
|
61
76
|
const suite = baseline.loadSuite(arg('suite', baseline.SUITE_FILE)).filter((f) => !only || f.name === only);
|
|
62
77
|
if (!suite.length) {
|
|
@@ -101,6 +116,35 @@ outer: for (let pass = 1; pass <= passes; pass += 1) {
|
|
|
101
116
|
const thisPass = new Set();
|
|
102
117
|
passSets.push(thisPass);
|
|
103
118
|
if (passes > 1) console.log(`pass ${pass}/${passes}`);
|
|
119
|
+
// **Recover between passes, not only after a failure.**
|
|
120
|
+
//
|
|
121
|
+
// Measured 2026-09-18: this device tolerates roughly 8-10 app launches before
|
|
122
|
+
// it stops being readable, and it degrades *within* a pass — the suite's own
|
|
123
|
+
// second flow went 9 s, 18 s, 30 s, then two incompletes, on a device that had
|
|
124
|
+
// run the same flow five times cleanly minutes earlier. `passes x runs` is 30
|
|
125
|
+
// runs at the default and 18 in CI, both far past that, with no recovery in
|
|
126
|
+
// between. So a multi-pass suite has never been physically completable and the
|
|
127
|
+
// gate has never produced a hosted-runner reading in this whole series.
|
|
128
|
+
//
|
|
129
|
+
// DEFERRED 173 listed this as worth trying. Nothing had tried it.
|
|
130
|
+
//
|
|
131
|
+
// Diagnosed first, revived only if needed: a revive is ~40 s and paying it
|
|
132
|
+
// when the device is fine would be spending the budget on superstition. The
|
|
133
|
+
// diagnosis is also the honest record of what state each pass started in —
|
|
134
|
+
// without it, a pass that began on a half-dead device is indistinguishable
|
|
135
|
+
// from one that did not.
|
|
136
|
+
if (pass > 1) {
|
|
137
|
+
const before = wedge.classify(await wedge.snapshot(dev.udid).catch(() => null));
|
|
138
|
+
console.log(` device before pass ${pass}: ${before?.state ?? 'unreadable'}`);
|
|
139
|
+
if (before?.state !== 'healthy') {
|
|
140
|
+
const revived = await wedge.revive(dev.udid, { device: dev });
|
|
141
|
+
console.log(` revived between passes — now ${revived.state}`);
|
|
142
|
+
if (!revived.usable) {
|
|
143
|
+
abort = { kind: 'the device could not be revived between passes', message: revived.verdict?.detail ?? '' };
|
|
144
|
+
break outer;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
104
148
|
for (const flow of suite) {
|
|
105
149
|
for (let run = 1; run <= runs; run += 1) {
|
|
106
150
|
// The same start state the human baseline was recorded from: app not
|
|
@@ -191,6 +235,14 @@ for (const f of report.flows) {
|
|
|
191
235
|
report.overall.hpi_time_median_of_passes = passTimes.length ? Number(metrics.median(passTimes).toFixed(3)) : null;
|
|
192
236
|
const o = report.overall;
|
|
193
237
|
console.log(`\nHPI_accuracy ${o.hpi_accuracy} HPI_time ${o.hpi_time ?? '—'} HPI ${o.hpi ?? '—'} step_ratio ${o.step_ratio ?? '—'}`);
|
|
238
|
+
if (o.runs_lost_to_device) {
|
|
239
|
+
// Measured on a full local suite: 5 of 17 runs failed because the guest's
|
|
240
|
+
// SpringBoard crashed or simctl stopped answering. They used to lower
|
|
241
|
+
// HPI_accuracy, which is the number this job gates on.
|
|
242
|
+
console.log(` over ${o.runs} measurable run(s); ${o.runs_lost_to_device} left the denominator`
|
|
243
|
+
+ ' because the DEVICE failed, not the code (item 173):');
|
|
244
|
+
for (const c of o.device_causes ?? []) console.log(` ${c}`);
|
|
245
|
+
}
|
|
194
246
|
if (passTimes.length > 1) {
|
|
195
247
|
console.log(`HPI_time per pass: ${passTimes.join(', ')} — median ${o.hpi_time_median_of_passes} (what the gate reads)`);
|
|
196
248
|
}
|
|
@@ -59,6 +59,25 @@ if (!cause) {
|
|
|
59
59
|
process.exit(first.code ?? 1);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// Diagnose BEFORE reviving, because reviving is what destroys the evidence.
|
|
63
|
+
//
|
|
64
|
+
// This guard has been recognising wedges from the shape of the failure text
|
|
65
|
+
// since 126, and then immediately power-cycling the device — so item 173 has
|
|
66
|
+
// accumulated a dozen occurrences and not one observation of what the device
|
|
67
|
+
// was doing at the time. `cause` above names a *consequence* ("a launched app
|
|
68
|
+
// never came to the front"); this names what the two sensors actually saw, and
|
|
69
|
+
// the difference decides between a stale framebuffer, a screen that is not the
|
|
70
|
+
// app, and capture being down. Its cost is one read on a path that is already
|
|
71
|
+
// failing.
|
|
72
|
+
const diagnose = async (when) => {
|
|
73
|
+
const d = await run(['node', 'src/cli.js', 'diagnose', `--device=${udid}`]);
|
|
74
|
+
console.error(`\n (diagnosis ${when} — for DEFERRED 173)`);
|
|
75
|
+
const verdict = /— (\S+)\n/.exec(d.out)?.[1] ?? 'unreadable';
|
|
76
|
+
summary(` - diagnosis ${when}: \`${verdict}\``);
|
|
77
|
+
return verdict;
|
|
78
|
+
};
|
|
79
|
+
const before = await diagnose('before the revive');
|
|
80
|
+
|
|
62
81
|
console.error(`\n (${cause} — DEFERRED 126. Reviving once and running again.)`);
|
|
63
82
|
await run(['node', 'src/cli.js', 'revive', `--device=${udid}`], { capture: false });
|
|
64
83
|
const second = await run(cmd);
|
|
@@ -70,4 +89,12 @@ if (second.code === 0) {
|
|
|
70
89
|
const again = deviceCause(second.out);
|
|
71
90
|
summary(`- \`${cmd.join(' ')}\` — **${again ? 'device unavailable' : 'check failed'}** after a revive${again ? ` (${again})` : ''}`);
|
|
72
91
|
if (again) console.error(`\nFAIL the simulator is still in a bad state after a revive: ${again}`);
|
|
92
|
+
// Twice is the interesting case: a revive cured it and it came back, or the
|
|
93
|
+
// revive did not cure it at all. Those are different faults and the pair of
|
|
94
|
+
// diagnoses says which.
|
|
95
|
+
const after = await diagnose('after the revive');
|
|
96
|
+
if (before !== after) {
|
|
97
|
+
console.error(`\n (the device changed state across the revive: ${before} -> ${after})`);
|
|
98
|
+
summary(` - state changed across the revive: \`${before}\` -> \`${after}\``);
|
|
99
|
+
}
|
|
73
100
|
process.exit(second.code ?? 1);
|
package/scripts/ci-memory.mjs
CHANGED
|
@@ -678,23 +678,50 @@ if (contradicted) {
|
|
|
678
678
|
// --force is the deliberate override, and it is what lets the replay machinery
|
|
679
679
|
// be tested without waiting on a screen that may never settle into one shape.
|
|
680
680
|
const forced = await jsonRetry(['do', LOOP, `--save=${FLOW_NAME}`, '--force'], { allowFail: true });
|
|
681
|
-
if (check(forced.saved?.ok === true, 'and --force saves it anyway',
|
|
681
|
+
if (check(forced.saved?.ok === true, 'and --force saves it anyway',
|
|
682
|
+
`${forced.saved?.steps} steps, provisional=${forced.saved?.provisional}`)) {
|
|
682
683
|
const listed = await jsonRetry(['flow', 'list']);
|
|
683
|
-
|
|
684
|
+
const before = listed.find((f) => f.name === FLOW_NAME);
|
|
685
|
+
check(Boolean(before), 'the saved flow is listed');
|
|
686
|
+
// Whether there is anything to promote is a fact about *this* save, not the
|
|
687
|
+
// first attempt's. The forced save is another traversal of the same loop,
|
|
688
|
+
// and by then the graph has seen every edge in it: measured on the bench
|
|
689
|
+
// device, the first run saved `provisional=true` and every run after it
|
|
690
|
+
// saved confirmed outright. Both checks below used to assume a provisional
|
|
691
|
+
// flow — so the positive one passed on a flow that had never been
|
|
692
|
+
// provisional, and the negative one failed CI on it — main was red on a
|
|
693
|
+
// precondition the harness never established, while the product did exactly
|
|
694
|
+
// what it should.
|
|
695
|
+
const wasProvisional = before?.provisional === true;
|
|
684
696
|
const replayed = await jsonRetry(['flow', 'run', FLOW_NAME], { allowFail: true });
|
|
697
|
+
// A replay that stopped early says which step stopped it and why. 1dcce18's
|
|
698
|
+
// red run printed `1/2 steps` and nothing else, so the one fact that would
|
|
699
|
+
// say whether the replay itself is broken was never on the page.
|
|
700
|
+
const stoppedAt = (replayed.results ?? []).find((r) => !r.ok);
|
|
685
701
|
check(replayed.ranSteps >= 1 && Array.isArray(replayed.results),
|
|
686
702
|
'and replays from disk with no model in the loop',
|
|
687
|
-
`${replayed.ranSteps}/${replayed.totalSteps} steps`
|
|
703
|
+
`${replayed.ranSteps}/${replayed.totalSteps} steps`
|
|
704
|
+
+ (stoppedAt ? `; stopped at [${stoppedAt.index}] ${stoppedAt.action}: ${String(stoppedAt.detail ?? '').slice(0, 160)}` : ''));
|
|
688
705
|
// The other half of the bootstrap, and the reason "provisional" is not a
|
|
689
706
|
// state nothing ever leaves: a clean replay is the confirmation a first
|
|
690
707
|
// traversal could not give. Only asserted when the replay actually ran to
|
|
691
708
|
// the end — a partial replay promotes nothing, deliberately.
|
|
692
709
|
const after = await jsonRetry(['flow', 'list']);
|
|
693
710
|
const entry = after.find((f) => f.name === FLOW_NAME);
|
|
694
|
-
|
|
711
|
+
// Whatever the replay did, it must not lose the flow. Neither branch below
|
|
712
|
+
// could say this: an entry that vanished and an entry without the field both
|
|
713
|
+
// read `undefined`.
|
|
714
|
+
check(Boolean(entry), 'a replay leaves the flow it replayed on disk',
|
|
715
|
+
entry ? '' : 'the entry is gone from the listing');
|
|
716
|
+
if (!wasProvisional) {
|
|
717
|
+
skip(replayed.ok
|
|
718
|
+
? 'and a clean replay confirms a provisional flow'
|
|
719
|
+
: 'and a replay that failed does NOT confirm the flow it just disproved',
|
|
720
|
+
'the forced save verified every step, so the flow was confirmed before the replay and there was nothing to promote');
|
|
721
|
+
} else if (replayed.ok) {
|
|
695
722
|
check(entry && !entry.provisional,
|
|
696
723
|
'and a clean replay confirms a provisional flow',
|
|
697
|
-
`provisional=${entry
|
|
724
|
+
`provisional=${entry ? entry.provisional : 'gone'}`);
|
|
698
725
|
} else {
|
|
699
726
|
// The other direction, and it is the one that was silently wrong: promotion
|
|
700
727
|
// keyed on `ranSteps === steps.length`, which a failure on the *last* step
|
|
@@ -703,7 +730,7 @@ if (check(forced.saved?.ok === true, 'and --force saves it anyway', `${forced.sa
|
|
|
703
730
|
// confirmation, so the negative case has to be checked too.
|
|
704
731
|
check(entry?.provisional === true,
|
|
705
732
|
'and a replay that failed does NOT confirm the flow it just disproved',
|
|
706
|
-
`ok=${replayed.ok}, provisional=${entry
|
|
733
|
+
`ok=${replayed.ok}, provisional=${entry ? entry.provisional : 'gone'}`);
|
|
707
734
|
}
|
|
708
735
|
const unknown = await cli(['flow', 'run', 'no-such-flow'], { expectFail: true });
|
|
709
736
|
check(/no flow/i.test(unknown), 'an unknown flow name is refused with what is known');
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# The README GIF, and how to remake it
|
|
2
|
+
|
|
3
|
+
`docs/simframe-demo.gif` is a `simctl` recording of the benchmark simulator
|
|
4
|
+
composed with the flow's own JSON verdicts. No dependency was added: the
|
|
5
|
+
compositor is one Swift file on AVFoundation, CoreText and ImageIO.
|
|
6
|
+
|
|
7
|
+
1. Boot a simulator, launch Settings so the flow starts at its root, then
|
|
8
|
+
record while the flow runs and keep the wall-clock bounds:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
D=<udid>
|
|
12
|
+
xcrun simctl launch $D com.apple.Preferences; sleep 4
|
|
13
|
+
xcrun simctl io $D recordVideo --codec h264 --force take.mp4 & REC=$!; sleep 4
|
|
14
|
+
T0=$(python3 -c 'import time;print(int(time.time()*1000))')
|
|
15
|
+
simframe do scripts/demo-gif/flow.json --device=$D --json > do.json
|
|
16
|
+
T1=$(python3 -c 'import time;print(int(time.time()*1000))')
|
|
17
|
+
sleep 2; kill -INT $REC; wait $REC
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Record with the app already launched: a `launch` step inside the recording
|
|
21
|
+
took 8–10 s on two takes because the launch did not front on the first
|
|
22
|
+
attempt while `recordVideo` was attached, against 89 ms without it.
|
|
23
|
+
|
|
24
|
+
2. Build `events.json` from `do.json`, `T0` and `T1` — the shape is in
|
|
25
|
+
`events.example.json`: every `t` is an epoch millisecond, and
|
|
26
|
+
`videoEndEpochMs` is `T1 + 2000`, which is how the compositor finds where
|
|
27
|
+
the recording starts without a timestamp from `simctl`.
|
|
28
|
+
|
|
29
|
+
3. Compose:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
swift scripts/demo-gif/compose.swift take.mp4 events.json docs/simframe-demo.gif
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
760×560, 10 fps, the last frame held two seconds; the example take is
|
|
36
|
+
85 frames and 336 KB.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Composes a README GIF: simulator recording on the left, a terminal panel on
|
|
2
|
+
// the right whose lines appear at the moments the flow printed them.
|
|
3
|
+
// usage: swift gif.swift <video.mp4> <events.json> <out.gif>
|
|
4
|
+
// events.json: {"startMs":..,"endMs":..,"fps":..,"lines":[{"t":ms,"text":".."}]}
|
|
5
|
+
// Times are milliseconds from the start of the video. No dependencies.
|
|
6
|
+
import Foundation
|
|
7
|
+
import AVFoundation
|
|
8
|
+
import ImageIO
|
|
9
|
+
import CoreText
|
|
10
|
+
import UniformTypeIdentifiers
|
|
11
|
+
|
|
12
|
+
struct Line: Decodable { let t: Double; let text: String; let color: String? }
|
|
13
|
+
struct Events: Decodable { let startMs: Double; let endMs: Double; let fps: Double; let lines: [Line]; let title: String?; let videoEndEpochMs: Double }
|
|
14
|
+
|
|
15
|
+
let args = CommandLine.arguments
|
|
16
|
+
let video = URL(fileURLWithPath: args[1])
|
|
17
|
+
let raw = try! JSONDecoder().decode(Events.self, from: Data(contentsOf: URL(fileURLWithPath: args[2])))
|
|
18
|
+
let asset0 = AVURLAsset(url: video)
|
|
19
|
+
let durMs = CMTimeGetSeconds(asset0.duration) * 1000
|
|
20
|
+
let vstart = raw.videoEndEpochMs - durMs
|
|
21
|
+
struct Ev { let startMs: Double; let endMs: Double; let fps: Double; let lines: [Line]; let title: String? }
|
|
22
|
+
let events = Ev(startMs: max(0, raw.startMs - vstart), endMs: min(durMs, raw.endMs - vstart), fps: raw.fps,
|
|
23
|
+
lines: raw.lines.map { Line(t: $0.t - vstart, text: $0.text, color: $0.color) }, title: raw.title)
|
|
24
|
+
print("video \(durMs) ms, window \(events.startMs)...\(events.endMs)")
|
|
25
|
+
let out = URL(fileURLWithPath: args[3])
|
|
26
|
+
|
|
27
|
+
let asset = AVURLAsset(url: video)
|
|
28
|
+
let gen = AVAssetImageGenerator(asset: asset)
|
|
29
|
+
gen.requestedTimeToleranceBefore = .zero
|
|
30
|
+
gen.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 30)
|
|
31
|
+
gen.appliesPreferredTrackTransform = true
|
|
32
|
+
|
|
33
|
+
let W = 760, H = 560, pad = 16
|
|
34
|
+
let simH = H - 2 * pad
|
|
35
|
+
let firstFrame = try! gen.copyCGImage(at: CMTime(seconds: events.startMs / 1000, preferredTimescale: 600), actualTime: nil)
|
|
36
|
+
let simW = Int(Double(simH) * Double(firstFrame.width) / Double(firstFrame.height))
|
|
37
|
+
let termX = pad + simW + pad
|
|
38
|
+
let termW = W - termX - pad
|
|
39
|
+
|
|
40
|
+
func color(_ hex: String) -> CGColor {
|
|
41
|
+
var v: UInt64 = 0; Scanner(string: String(hex.dropFirst())).scanHexInt64(&v)
|
|
42
|
+
return CGColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255, green: CGFloat((v >> 8) & 0xff) / 255, blue: CGFloat(v & 0xff) / 255, alpha: 1)
|
|
43
|
+
}
|
|
44
|
+
let bg = color("#0b0f14"), termBg = color("#111820"), fg = color("#d8dee6"), dim = color("#7d8590"), ok = color("#3fb950"), accent = color("#79c0ff")
|
|
45
|
+
let font = CTFontCreateWithName("Menlo" as CFString, 12.5, nil)
|
|
46
|
+
let boldFont = CTFontCreateWithName("Menlo-Bold" as CFString, 12.5, nil)
|
|
47
|
+
|
|
48
|
+
func wrap(_ s: String, width: CGFloat, font: CTFont) -> [String] {
|
|
49
|
+
// greedy word wrap measured with the real font
|
|
50
|
+
var lines: [String] = []; var cur = ""
|
|
51
|
+
for word in s.split(separator: " ", omittingEmptySubsequences: false) {
|
|
52
|
+
let cand = cur.isEmpty ? String(word) : cur + " " + word
|
|
53
|
+
let l = CTLineCreateWithAttributedString(NSAttributedString(string: cand, attributes: [kCTFontAttributeName as NSAttributedString.Key: font]))
|
|
54
|
+
if CTLineGetTypographicBounds(l, nil, nil, nil) > Double(width) && !cur.isEmpty { lines.append(cur); cur = String(word) } else { cur = cand }
|
|
55
|
+
}
|
|
56
|
+
if !cur.isEmpty { lines.append(cur) }
|
|
57
|
+
return lines
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func draw(ctx: CGContext, text: String, x: CGFloat, y: CGFloat, color: CGColor, font: CTFont) {
|
|
61
|
+
let l = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: [kCTFontAttributeName as NSAttributedString.Key: font, kCTForegroundColorAttributeName as NSAttributedString.Key: color]))
|
|
62
|
+
ctx.textPosition = CGPoint(x: x, y: y); CTLineDraw(l, ctx)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func render(at ms: Double) -> CGImage {
|
|
66
|
+
let cs = CGColorSpace(name: CGColorSpace.sRGB)!
|
|
67
|
+
let ctx = CGContext(data: nil, width: W, height: H, bitsPerComponent: 8, bytesPerRow: 0, space: cs, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)!
|
|
68
|
+
ctx.setFillColor(bg); ctx.fill(CGRect(x: 0, y: 0, width: W, height: H))
|
|
69
|
+
// simulator frame, rounded corners
|
|
70
|
+
let frame = (try? gen.copyCGImage(at: CMTime(seconds: ms / 1000, preferredTimescale: 600), actualTime: nil)) ?? firstFrame
|
|
71
|
+
let simRect = CGRect(x: pad, y: pad, width: simW, height: simH)
|
|
72
|
+
ctx.saveGState()
|
|
73
|
+
ctx.addPath(CGPath(roundedRect: simRect, cornerWidth: 22, cornerHeight: 22, transform: nil)); ctx.clip()
|
|
74
|
+
ctx.draw(frame, in: simRect)
|
|
75
|
+
ctx.restoreGState()
|
|
76
|
+
// terminal panel
|
|
77
|
+
let termRect = CGRect(x: termX, y: pad, width: termW, height: simH)
|
|
78
|
+
ctx.setFillColor(termBg); ctx.addPath(CGPath(roundedRect: termRect, cornerWidth: 10, cornerHeight: 10, transform: nil)); ctx.fillPath()
|
|
79
|
+
var y = CGFloat(H - pad - 28)
|
|
80
|
+
let lx = CGFloat(termX + 14), lw = CGFloat(termW - 28)
|
|
81
|
+
if let t = events.title { draw(ctx: ctx, text: t, x: lx, y: y, color: dim, font: font); y -= 26 }
|
|
82
|
+
for line in events.lines where line.t <= ms {
|
|
83
|
+
let c: CGColor = line.color == "ok" ? ok : line.color == "accent" ? accent : line.color == "dim" ? dim : fg
|
|
84
|
+
let f = line.color == "accent" ? boldFont : font
|
|
85
|
+
for (i, piece) in wrap(line.text, width: lw, font: f).enumerated() {
|
|
86
|
+
draw(ctx: ctx, text: piece, x: lx + (i > 0 ? 28 : 0), y: y, color: c, font: f); y -= 18
|
|
87
|
+
}
|
|
88
|
+
y -= 6
|
|
89
|
+
}
|
|
90
|
+
// cursor
|
|
91
|
+
let blink = Int(ms / 500) % 2 == 0
|
|
92
|
+
if blink { ctx.setFillColor(fg); ctx.fill(CGRect(x: lx, y: y + 2, width: 8, height: 14)) }
|
|
93
|
+
return ctx.makeImage()!
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let n = Int((events.endMs - events.startMs) / 1000 * events.fps)
|
|
97
|
+
let dest = CGImageDestinationCreateWithURL(out as CFURL, UTType.gif.identifier as CFString, n, nil)!
|
|
98
|
+
CGImageDestinationSetProperties(dest, [kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFLoopCount: 0]] as CFDictionary)
|
|
99
|
+
for i in 0..<n {
|
|
100
|
+
let ms = events.startMs + Double(i) * 1000 / events.fps
|
|
101
|
+
let img = render(at: ms)
|
|
102
|
+
let delay = (i == n - 1) ? 2.0 : 1 / events.fps
|
|
103
|
+
CGImageDestinationAddImage(dest, img, [kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFDelayTime: delay, kCGImagePropertyGIFUnclampedDelayTime: delay]] as CFDictionary)
|
|
104
|
+
}
|
|
105
|
+
CGImageDestinationFinalize(dest)
|
|
106
|
+
print("wrote \(out.path) \(n) frames \(W)x\(H)")
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"startMs": 1790339118840,
|
|
3
|
+
"endMs": 1790339128578,
|
|
4
|
+
"fps": 10,
|
|
5
|
+
"videoEndEpochMs": 1790339127378,
|
|
6
|
+
"title": "iPhone 17 Pro \u00b7 iOS 26.5 \u00b7 one call from the agent",
|
|
7
|
+
"lines": [
|
|
8
|
+
{
|
|
9
|
+
"t": 1790339119740,
|
|
10
|
+
"text": "$ simframe do flow.json",
|
|
11
|
+
"color": "accent"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"t": 1790339122213,
|
|
15
|
+
"text": "ok [0] tap: tapped \"Accessibility\" at 201,380",
|
|
16
|
+
"color": "ok"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"t": 1790339123369,
|
|
20
|
+
"text": "ok [1] tap: tapped \"Display & Text Size\" at 201,504",
|
|
21
|
+
"color": "ok"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"t": 1790339124840,
|
|
25
|
+
"text": "ok [2] tap: tapped \"Larger Text\" at 201,216",
|
|
26
|
+
"color": "ok"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"t": 1790339125080,
|
|
30
|
+
"text": "ok [3] assert: Larger Text is on screen at 201,84",
|
|
31
|
+
"color": "ok"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"t": 1790339125528,
|
|
35
|
+
"text": "flow completed \u2014 4/4 steps in 4645ms",
|
|
36
|
+
"color": "fg"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"t": 1790339125878,
|
|
40
|
+
"text": "iPhone 17 Pro \u00b7 402x874pt \u00b7 screen f0509d55 (known)",
|
|
41
|
+
"color": "dim"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"t": 1790339125938,
|
|
45
|
+
"text": "content:",
|
|
46
|
+
"color": "fg"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"t": 1790339125998,
|
|
50
|
+
"text": "\u00a0\u00a0#1 button 38,84 Display & Text Size",
|
|
51
|
+
"color": "fg"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"t": 1790339126058,
|
|
55
|
+
"text": "\u00a0\u00a0#2 heading 201,84 Larger Text",
|
|
56
|
+
"color": "fg"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"t": 1790339126118,
|
|
60
|
+
"text": "\u00a0\u00a0#3 switch 337,161 Larger Accessibility Sizes = 0",
|
|
61
|
+
"color": "fg"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"t": 1790339126178,
|
|
65
|
+
"text": "\u00a0\u00a0#5 slider 200,783 DYNAMIC_TYPE_SLIDER = 50%",
|
|
66
|
+
"color": "fg"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"t": 1790339126238,
|
|
70
|
+
"text": "next: settled; nothing ambiguous \u2014 chain the next steps in one call",
|
|
71
|
+
"color": "dim"
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|