nativemason 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/README.md +4 -2
- package/bin/nativemason.js +13 -0
- package/dist/application/index.d.ts +34 -0
- package/dist/application/index.js +591 -0
- package/dist/browser/accessibility.d.ts +4 -0
- package/dist/browser/accessibility.js +93 -0
- package/dist/browser/index.d.ts +16 -0
- package/dist/browser/index.js +495 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +23 -0
- package/dist/core/accessibility.d.ts +5 -0
- package/dist/core/accessibility.js +55 -0
- package/dist/core/design-intent.d.ts +3 -0
- package/dist/core/design-intent.js +108 -0
- package/dist/core/index.d.ts +15 -0
- package/dist/core/index.js +324 -0
- package/dist/editor/index.d.ts +4 -0
- package/dist/editor/index.js +4 -0
- package/dist/editor/planner.d.ts +4 -0
- package/dist/editor/planner.js +370 -0
- package/dist/editor/source-format.d.ts +6 -0
- package/dist/editor/source-format.js +32 -0
- package/dist/editor/source-port.d.ts +3 -0
- package/dist/editor/source-port.js +79 -0
- package/dist/editor/transaction-planner.d.ts +3 -0
- package/dist/editor/transaction-planner.js +231 -0
- package/dist/editor/transaction-port.d.ts +3 -0
- package/dist/editor/transaction-port.js +228 -0
- package/dist/evidence/report.d.ts +4 -0
- package/dist/evidence/report.js +180 -0
- package/dist/init/index.d.ts +26 -0
- package/dist/init/index.js +81 -0
- package/dist/instrumentation/index.d.ts +25 -0
- package/dist/instrumentation/index.js +235 -0
- package/dist/mcp/index.d.ts +3 -0
- package/dist/mcp/index.js +42 -0
- package/dist/native/index.d.ts +4 -0
- package/dist/native/index.js +218 -0
- package/dist/overlay/index.d.ts +3 -0
- package/dist/overlay/index.js +35 -0
- package/dist/scenario/index.d.ts +5 -0
- package/dist/scenario/index.js +306 -0
- package/package.json +46 -3
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import { importDesignIntent } from '../core/design-intent.js';
|
|
2
|
+
import { inspectAccessibility } from '../core/accessibility.js';
|
|
3
|
+
import { createNativeScenarioPort, createNativeProfilePort } from '../scenario/index.js';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { BridgeFault, ToolSchemas, ToolOutputSchemas } from '@nativemason/contracts';
|
|
8
|
+
import { evaluateConstraints, diffSnapshots, buildOverlayScene, profilesMatch, generateFixCandidates, inferConstraints, evaluateDefaultRules } from '../core/index.js';
|
|
9
|
+
import { createBrowserBridge } from '../browser/index.js';
|
|
10
|
+
import { createNativeBridge } from '../native/index.js';
|
|
11
|
+
import { createTransactionSourcePort, planTransaction } from '../editor/index.js';
|
|
12
|
+
import { renderOverlay } from '../overlay/index.js';
|
|
13
|
+
function allPass(results) { return results.every(r => r.status === 'pass'); }
|
|
14
|
+
function fingerprint(s) { return JSON.stringify({ platform: s.platform, profile: s.profile, scenario: s.scenarioId, generation: s.generation, states: s.nodes.filter(n => n.pressableState !== undefined).map(n => [n.id, n.pressableState]).sort(), revisions: Object.entries(s.revisions).sort() }); }
|
|
15
|
+
function remember(s, snapshot) { s.snapshots.set(snapshot.snapshotId, snapshot); if (s.snapshots.size > 32)
|
|
16
|
+
s.snapshots.delete(s.snapshots.keys().next().value); }
|
|
17
|
+
function pendingInteractions(snapshot, requirements) { return requirements.filter(requirement => snapshot.nodes.find(node => node.id === requirement.elementId)?.pressableState !== (requirement.pressed ? 'pressed' : 'rest')); }
|
|
18
|
+
function overrideKey(value) { return JSON.stringify(value ?? {}); }
|
|
19
|
+
function specForProfile(spec, snapshot) { return { ...spec, profile: snapshot.profile }; }
|
|
20
|
+
export class BridgeApplication {
|
|
21
|
+
sessions = new Map();
|
|
22
|
+
deps;
|
|
23
|
+
constructor(deps = {}) {
|
|
24
|
+
this.deps = { browser: createBrowserBridge, native: createNativeBridge, source: createTransactionSourcePort, artifactsRoot: path.resolve('artifacts/nativemason'), ...deps };
|
|
25
|
+
}
|
|
26
|
+
async execute(name, input) {
|
|
27
|
+
return ToolOutputSchemas[name].parse(await this.dispatch(name, input));
|
|
28
|
+
}
|
|
29
|
+
async dispatch(name, input) {
|
|
30
|
+
const parsed = ToolSchemas[name].safeParse(input);
|
|
31
|
+
if (!parsed.success)
|
|
32
|
+
throw new BridgeFault('INVALID_INPUT', parsed.error.message);
|
|
33
|
+
if (name === 'open_session')
|
|
34
|
+
return this.open(parsed.data);
|
|
35
|
+
const sessionId = parsed.data.sessionId;
|
|
36
|
+
const s = this.sessions.get(sessionId);
|
|
37
|
+
if (!s)
|
|
38
|
+
throw new BridgeFault('SESSION_NOT_FOUND', 'Session does not exist');
|
|
39
|
+
if (s.busy)
|
|
40
|
+
throw new BridgeFault('INVALID_INPUT', 'Another operation is in progress for this session');
|
|
41
|
+
s.busy = true;
|
|
42
|
+
try {
|
|
43
|
+
switch (name) {
|
|
44
|
+
case 'import_design_intent': {
|
|
45
|
+
const { design } = ToolSchemas.import_design_intent.parse(input);
|
|
46
|
+
if (design.scenarioId !== s.scenarioId || !profilesMatch(design.profile, s.profile))
|
|
47
|
+
throw new BridgeFault('INVALID_INPUT', 'Design target must match the session scenario and profile');
|
|
48
|
+
return importDesignIntent(design);
|
|
49
|
+
}
|
|
50
|
+
case 'inspect_accessibility': {
|
|
51
|
+
const { target } = ToolSchemas.inspect_accessibility.parse(input);
|
|
52
|
+
let snapshot;
|
|
53
|
+
if (target === 'native')
|
|
54
|
+
snapshot = await s.native.capture((await this.capture(s)).revisions);
|
|
55
|
+
else
|
|
56
|
+
snapshot = s.browser.captureAccessibility ? await s.browser.captureAccessibility() : await this.capture(s);
|
|
57
|
+
remember(s, snapshot);
|
|
58
|
+
return { snapshot, findings: inspectAccessibility(snapshot) };
|
|
59
|
+
}
|
|
60
|
+
case 'load_wireframe_spec': {
|
|
61
|
+
const { spec } = ToolSchemas.load_wireframe_spec.parse(input);
|
|
62
|
+
if (spec.scenarioId !== s.scenarioId || !profilesMatch(spec.profile, s.profile))
|
|
63
|
+
throw new BridgeFault('INVALID_INPUT', 'Spec scenario and profile must match the session');
|
|
64
|
+
s.spec = spec;
|
|
65
|
+
s.trial = undefined;
|
|
66
|
+
s.baseline = undefined;
|
|
67
|
+
s.baselineSpec = undefined;
|
|
68
|
+
s.baselineOverrideKey = undefined;
|
|
69
|
+
s.baselineOverrides = undefined;
|
|
70
|
+
return { specId: randomUUID() };
|
|
71
|
+
}
|
|
72
|
+
case 'inspect_ui_spatial_graph': {
|
|
73
|
+
const { profileOverrides } = ToolSchemas.inspect_ui_spatial_graph.parse(input);
|
|
74
|
+
await s.browser.resetTrial();
|
|
75
|
+
s.trial = undefined;
|
|
76
|
+
const snapshot = await this.capture(s, profileOverrides);
|
|
77
|
+
s.baseline = snapshot;
|
|
78
|
+
s.baselineOverrideKey = overrideKey(profileOverrides);
|
|
79
|
+
s.baselineOverrides = profileOverrides ? structuredClone(profileOverrides) : undefined;
|
|
80
|
+
s.baselineSpec = s.spec ? specForProfile(s.spec, snapshot) : undefined;
|
|
81
|
+
return { snapshot, results: s.baselineSpec ? evaluateConstraints(snapshot, s.baselineSpec) : [], webDiagnostics: snapshot.webDiagnostics };
|
|
82
|
+
}
|
|
83
|
+
case 'capture_annotated_overlay': {
|
|
84
|
+
const { snapshotId } = ToolSchemas.capture_annotated_overlay.parse(input);
|
|
85
|
+
const snapshot = s.snapshots.get(snapshotId);
|
|
86
|
+
if (!snapshot)
|
|
87
|
+
throw new BridgeFault('STALE_SNAPSHOT', 'Snapshot no longer exists');
|
|
88
|
+
if (snapshot.platform !== 'web')
|
|
89
|
+
throw new BridgeFault('UNSUPPORTED_ACTION', 'Annotated overlays require a browser snapshot');
|
|
90
|
+
const live = await s.browser.capture();
|
|
91
|
+
if (fingerprint(live) !== fingerprint(snapshot) || JSON.stringify(live.nodes.map(n => n.rect)) !== JSON.stringify(snapshot.nodes.map(n => n.rect)))
|
|
92
|
+
throw new BridgeFault('STALE_SNAPSHOT', 'Live UI no longer matches the requested screenshot');
|
|
93
|
+
const scene = buildOverlayScene(snapshot, this.spec(s));
|
|
94
|
+
const pixels = await s.browser.screenshot();
|
|
95
|
+
const after = await s.browser.capture();
|
|
96
|
+
if (fingerprint(after) !== fingerprint(snapshot) || JSON.stringify(after.nodes.map(n => ({ id: n.id, rect: n.rect }))) !== JSON.stringify(snapshot.nodes.map(n => ({ id: n.id, rect: n.rect }))))
|
|
97
|
+
throw new BridgeFault('STALE_SNAPSHOT', 'UI changed while capturing screenshot');
|
|
98
|
+
const png = await renderOverlay(pixels, scene, s.profile.pixelRatio);
|
|
99
|
+
const artifact = await this.artifact(s.id, `${snapshotId}.png`, png);
|
|
100
|
+
return { image: Buffer.from(png).toString('base64'), mimeType: 'image/png', artifact, marks: scene.marks };
|
|
101
|
+
}
|
|
102
|
+
case 'simulate_layout_mutation': {
|
|
103
|
+
const { baselineSnapshotId, elementId, patch, mutations, profileOverrides } = ToolSchemas.simulate_layout_mutation.parse(input);
|
|
104
|
+
const baseline = s.baseline;
|
|
105
|
+
if (!baseline || baseline.snapshotId !== baselineSnapshotId)
|
|
106
|
+
throw new BridgeFault('STALE_SNAPSHOT', 'Inspect the UI before starting a trial');
|
|
107
|
+
if (overrideKey(profileOverrides) !== s.baselineOverrideKey)
|
|
108
|
+
throw new BridgeFault('STALE_SNAPSHOT', 'Profile overrides differ from the inspected baseline; inspect again for each profile');
|
|
109
|
+
await this.assertRevisions(s, baseline);
|
|
110
|
+
if (mutations && patch === null)
|
|
111
|
+
throw new BridgeFault('INVALID_INPUT', 'Reset cannot include mutations');
|
|
112
|
+
if (patch === null) {
|
|
113
|
+
await s.browser.resetTrial();
|
|
114
|
+
s.trial = undefined;
|
|
115
|
+
const snapshot = await this.capture(s, profileOverrides);
|
|
116
|
+
s.baseline = snapshot;
|
|
117
|
+
return { snapshot, results: s.baselineSpec ? evaluateConstraints(snapshot, s.baselineSpec) : [], webDiagnostics: snapshot.webDiagnostics };
|
|
118
|
+
}
|
|
119
|
+
if (mutations && (elementId !== undefined || patch !== undefined))
|
|
120
|
+
throw new BridgeFault('INVALID_INPUT', 'Supply either mutations or the single-element shorthand');
|
|
121
|
+
const batch = mutations ?? (elementId && patch ? [{ elementId, patch }] : []);
|
|
122
|
+
if (!batch.length || new Set(batch.map(m => m.elementId)).size !== batch.length)
|
|
123
|
+
throw new BridgeFault('INVALID_INPUT', 'Supply unique mutations or elementId and patch');
|
|
124
|
+
for (const m of batch)
|
|
125
|
+
if (!baseline.nodes.some(n => n.id === m.elementId))
|
|
126
|
+
throw new BridgeFault('MISSING_ELEMENT', m.elementId);
|
|
127
|
+
s.trial = undefined;
|
|
128
|
+
const snapshot = batch.length === 1 ? await s.browser.trial(batch[0].elementId, batch[0].patch, profileOverrides) : s.browser.trialBatch ? await s.browser.trialBatch(batch, profileOverrides) : (() => { throw new BridgeFault('UNSUPPORTED_EDIT', 'Browser does not support batch trials'); })();
|
|
129
|
+
if (fingerprint(snapshot) !== fingerprint(baseline))
|
|
130
|
+
throw new BridgeFault('STALE_SNAPSHOT', 'Layout identity changed during trial');
|
|
131
|
+
remember(s, snapshot);
|
|
132
|
+
const trial = { trialId: randomUUID(), baselineSnapshotId, elementId: batch[0].elementId, patch: batch[0].patch, mutations: batch, snapshot, delta: diffSnapshots(baseline, snapshot), results: [...evaluateConstraints(snapshot, s.baselineSpec ?? this.spec(s))] };
|
|
133
|
+
s.trial = trial;
|
|
134
|
+
return { ...trial, webDiagnostics: snapshot.webDiagnostics };
|
|
135
|
+
}
|
|
136
|
+
case 'find_elements': {
|
|
137
|
+
const { query } = ToolSchemas.find_elements.parse(input);
|
|
138
|
+
const snapshot = await this.capture(s);
|
|
139
|
+
return { nodes: snapshot.nodes.filter(n => [n.id, n.role, n.kind, n.source?.path].some(v => v?.toLowerCase().includes(query.toLowerCase()))) };
|
|
140
|
+
}
|
|
141
|
+
case 'infer_constraints': {
|
|
142
|
+
const { snapshotId, options } = ToolSchemas.infer_constraints.parse(input);
|
|
143
|
+
const snapshot = s.snapshots.get(snapshotId);
|
|
144
|
+
if (!snapshot)
|
|
145
|
+
throw new BridgeFault('STALE_SNAPSHOT', 'Snapshot expired');
|
|
146
|
+
return { spec: inferConstraints(snapshot, options), findings: evaluateDefaultRules(snapshot) };
|
|
147
|
+
}
|
|
148
|
+
case 'suggest_fix': return this.suggest(s, ToolSchemas.suggest_fix.parse(input));
|
|
149
|
+
case 'run_scenario': {
|
|
150
|
+
const { script, target } = ToolSchemas.run_scenario.parse(input);
|
|
151
|
+
if (script.scenarioId !== s.scenarioId)
|
|
152
|
+
throw new BridgeFault('INVALID_INPUT', 'Script scenario differs from session');
|
|
153
|
+
await s.browser.resetTrial();
|
|
154
|
+
s.trial = undefined;
|
|
155
|
+
s.baseline = undefined;
|
|
156
|
+
if (target === 'native') {
|
|
157
|
+
if (!s.nativeScenario)
|
|
158
|
+
throw new BridgeFault('UNSUPPORTED_ACTION', 'Configure nativeTarget and nativeScenario on open_session');
|
|
159
|
+
await s.nativeScenario.run(script);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
if (!s.browser.runScenario)
|
|
163
|
+
throw new BridgeFault('UNSUPPORTED_ACTION', 'Browser scenario driver unavailable');
|
|
164
|
+
await s.browser.runScenario(script);
|
|
165
|
+
}
|
|
166
|
+
const snapshot = target === 'native' ? await s.native.capture((await this.capture(s)).revisions) : await this.capture(s);
|
|
167
|
+
return { snapshot, results: s.spec ? evaluateConstraints(snapshot, s.spec) : [] };
|
|
168
|
+
}
|
|
169
|
+
case 'run_profile_matrix': {
|
|
170
|
+
const { script, profiles, target } = ToolSchemas.run_profile_matrix.parse(input);
|
|
171
|
+
this.spec(s);
|
|
172
|
+
if (script.scenarioId !== s.scenarioId)
|
|
173
|
+
throw new BridgeFault('INVALID_INPUT', 'Script scenario differs from session');
|
|
174
|
+
const cells = [];
|
|
175
|
+
s.trial = undefined;
|
|
176
|
+
s.baseline = undefined;
|
|
177
|
+
for (const profile of profiles) {
|
|
178
|
+
try {
|
|
179
|
+
if (script.scenarioId !== s.scenarioId)
|
|
180
|
+
throw new BridgeFault('INVALID_INPUT', 'Script scenario differs from session');
|
|
181
|
+
await s.browser.resetTrial();
|
|
182
|
+
const run = async () => { if (target === 'native') {
|
|
183
|
+
if (!s.nativeScenario)
|
|
184
|
+
throw new BridgeFault('UNSUPPORTED_ACTION', 'Native scenario driver not configured');
|
|
185
|
+
await s.nativeScenario.run(script);
|
|
186
|
+
return s.native.capture((await this.capture(s)).revisions);
|
|
187
|
+
} if (!s.browser.runScenario)
|
|
188
|
+
throw new BridgeFault('UNSUPPORTED_ACTION', 'Browser scenario driver unavailable'); await this.capture(s, profile); await s.browser.runScenario(script); return this.capture(s, profile); };
|
|
189
|
+
if (target === 'native' && !s.nativeProfiles)
|
|
190
|
+
throw new BridgeFault('UNSUPPORTED_ACTION', 'Native profile driver not configured');
|
|
191
|
+
const snapshot = target === 'native' ? await s.nativeProfiles.withProfile(profile, run) : await run();
|
|
192
|
+
if (snapshot.status !== 'stable')
|
|
193
|
+
throw new BridgeFault('UNSTABLE_LAYOUT', 'Matrix layout did not settle');
|
|
194
|
+
if ((profile.fontScale !== undefined && Math.abs(snapshot.profile.fontScale - profile.fontScale) > 0.01) || (profile.orientation && snapshot.profile.orientation !== profile.orientation))
|
|
195
|
+
throw new BridgeFault('UNSUPPORTED_PROFILE', 'Measured native profile differs');
|
|
196
|
+
const results = s.spec ? evaluateConstraints(snapshot, specForProfile(s.spec, snapshot)) : [];
|
|
197
|
+
cells.push({ targetId: target === 'native' ? (s.nativeTarget?.targetId ?? 'native') : 'web', scenarioId: script.scenarioId, orientation: snapshot.profile.orientation, fontScale: snapshot.profile.fontScale, status: results.length && allPass(results) ? 'passed' : 'failed', snapshotId: snapshot.snapshotId });
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
cells.push({ targetId: target, scenarioId: script.scenarioId, orientation: profile.orientation ?? s.profile.orientation, fontScale: profile.fontScale ?? s.profile.fontScale, status: error instanceof BridgeFault && ['UNSUPPORTED_PROFILE', 'UNSUPPORTED_ACTION'].includes(error.code) ? 'unsupported' : 'unavailable', message: error instanceof Error ? error.message : String(error) });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await s.browser.resetTrial();
|
|
204
|
+
await this.artifact(s.id, 'matrix.json', JSON.stringify(cells, null, 2));
|
|
205
|
+
return { cells };
|
|
206
|
+
}
|
|
207
|
+
case 'revert_commit': {
|
|
208
|
+
const { commitId } = ToolSchemas.revert_commit.parse(input);
|
|
209
|
+
const source = s.source;
|
|
210
|
+
if (!source.revert)
|
|
211
|
+
throw new BridgeFault('UNSUPPORTED_EDIT', 'Source adapter cannot revert');
|
|
212
|
+
const original = s.commits.get(commitId), spec = structuredClone(original?.spec ?? this.spec(s));
|
|
213
|
+
const tx = await source.revert(commitId), first = tx.files[0];
|
|
214
|
+
const receipt = { ...tx, ...first, diff: tx.files.map(f => f.diff).join('\n') };
|
|
215
|
+
s.baseline = undefined;
|
|
216
|
+
s.trial = undefined;
|
|
217
|
+
const file = await source.read(first.path);
|
|
218
|
+
const plan = { source: { path: first.path, hash: first.newHash, start: 0, end: 1, line: 1, column: 0 }, expectedHash: first.oldHash, updatedText: file.text, diff: first.diff };
|
|
219
|
+
const entry = { receipt, web: null, plan, trialId: 'revert:' + commitId, spec, pendingNativeConstraints: original?.pendingNativeConstraints ?? [], interactions: original?.interactions ?? [] };
|
|
220
|
+
s.commits.set(receipt.commitId, entry);
|
|
221
|
+
entry.web = await this.captureCommitted(s, receipt);
|
|
222
|
+
return { receipt, snapshot: entry.web, nativeStatus: 'pending' };
|
|
223
|
+
}
|
|
224
|
+
case 'commit_rn_ast_patch': return await this.commit(s, ToolSchemas.commit_rn_ast_patch.parse(input));
|
|
225
|
+
case 'verify_native_layout': return await this.verify(s, ToolSchemas.verify_native_layout.parse(input).commitId);
|
|
226
|
+
case 'close_session': {
|
|
227
|
+
const closed = await Promise.allSettled([s.browser.close(), s.native.close(), s.nativeScenario?.close()]);
|
|
228
|
+
this.sessions.delete(s.id);
|
|
229
|
+
const failure = closed.find(x => x.status === 'rejected');
|
|
230
|
+
if (failure?.status === 'rejected')
|
|
231
|
+
throw failure.reason;
|
|
232
|
+
return { closed: true };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
s.busy = false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async suggest(s, input) {
|
|
241
|
+
const baseline = s.baseline;
|
|
242
|
+
if (!baseline || baseline.snapshotId !== input.baselineSnapshotId)
|
|
243
|
+
throw new BridgeFault('STALE_SNAPSHOT', 'Inspect again');
|
|
244
|
+
await this.assertRevisions(s, baseline);
|
|
245
|
+
const candidates = [];
|
|
246
|
+
const primarySpec = s.baselineSpec ?? this.spec(s);
|
|
247
|
+
if (input.profiles?.some(p => p.script && p.script.scenarioId !== s.scenarioId))
|
|
248
|
+
throw new BridgeFault('INVALID_INPUT', 'Profile scenario scripts must match the session scenario');
|
|
249
|
+
const contexts = [{ id: 'baseline', profile: baseline.profile, port: s.browser, snapshot: baseline, spec: primarySpec, scenarioState: 'baseline' }];
|
|
250
|
+
const opened = [];
|
|
251
|
+
let baselines = [];
|
|
252
|
+
const errorValue = (error) => ({ code: error instanceof BridgeFault ? error.code : 'INTERNAL_ERROR', message: error instanceof Error ? error.message : String(error) });
|
|
253
|
+
const resultStatus = (results) => !results.length || results.some(result => result.status === 'unavailable') ? 'unavailable' : allPass(results) ? 'passed' : 'failed';
|
|
254
|
+
const unavailableProfile = (error) => error?.code === 'UNSUPPORTED_PROFILE' || error?.code === 'UNSUPPORTED_ACTION' ? 'unsupported' : 'unavailable';
|
|
255
|
+
let nativeBaseline;
|
|
256
|
+
s.trial = undefined;
|
|
257
|
+
try {
|
|
258
|
+
if (input.includeNativeEvidence) {
|
|
259
|
+
try {
|
|
260
|
+
const snapshot = await s.native.capture(baseline.revisions);
|
|
261
|
+
const ids = snapshot.nodes.map(node => node.id);
|
|
262
|
+
if (snapshot.platform === 'web' || snapshot.status !== 'stable' || snapshot.sessionId !== s.id || snapshot.scenarioId !== s.scenarioId || !profilesMatch(snapshot.profile, baseline.profile) || new Set(ids).size !== ids.length || Object.entries(baseline.revisions).some(([file, revision]) => snapshot.revisions[file] !== revision) || (s.nativeTarget && (snapshot.platform !== s.nativeTarget.platform || snapshot.nativeTarget?.targetId !== s.nativeTarget.targetId)))
|
|
263
|
+
throw new BridgeFault('STALE_SNAPSHOT', 'Native baseline does not match the current session, profile, and source revisions');
|
|
264
|
+
await this.assertRevisions(s, snapshot);
|
|
265
|
+
nativeBaseline = { status: 'captured', snapshot, results: [...evaluateConstraints(snapshot, primarySpec)] };
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
nativeBaseline = { status: error instanceof BridgeFault && ['UNSUPPORTED_ACTION', 'UNSUPPORTED_PROFILE'].includes(error.code) ? 'unsupported' : 'unavailable', results: [], error: errorValue(error) };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
for (const requested of input.profiles ?? []) {
|
|
272
|
+
const context = { id: requested.id, profile: requested.profile };
|
|
273
|
+
contexts.push(context);
|
|
274
|
+
try {
|
|
275
|
+
if (requested.profile.fontScale !== 1)
|
|
276
|
+
throw new BridgeFault('UNSUPPORTED_PROFILE', 'Chromium font-scale simulation has no rendered-text proof');
|
|
277
|
+
const port = await this.deps.browser({ sessionId: s.id, scenarioId: s.scenarioId, webUrl: s.webUrl, profile: requested.profile });
|
|
278
|
+
opened.push(port);
|
|
279
|
+
context.port = port;
|
|
280
|
+
context.scenarioState = 'initial';
|
|
281
|
+
if (requested.script) {
|
|
282
|
+
if (!port.runScenario)
|
|
283
|
+
throw new BridgeFault('UNSUPPORTED_ACTION', 'Profile-specific scenarios require a browser scenario driver');
|
|
284
|
+
await port.runScenario(requested.script);
|
|
285
|
+
context.scenarioState = 'scripted';
|
|
286
|
+
}
|
|
287
|
+
context.snapshot = await port.capture();
|
|
288
|
+
await this.assertRevisions(s, context.snapshot);
|
|
289
|
+
context.spec = specForProfile(primarySpec, context.snapshot);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
context.error = errorValue(error);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
baselines = contexts.map(context => {
|
|
296
|
+
if (context.error)
|
|
297
|
+
return { profileId: context.id, platform: 'web', profile: context.profile, scenarioState: context.scenarioState, status: unavailableProfile(context.error), results: [], regressions: 0, warnings: 0, error: context.error };
|
|
298
|
+
const results = evaluateConstraints(context.snapshot, context.spec);
|
|
299
|
+
return { profileId: context.id, platform: 'web', profile: context.profile, scenarioState: context.scenarioState, snapshotId: context.snapshot.snapshotId, status: resultStatus(results), results: [...results], regressions: 0, warnings: context.snapshot.webDiagnostics?.warnings.filter(w => w.severity === 'confirmed').length ?? 0 };
|
|
300
|
+
});
|
|
301
|
+
const proposals = new Map();
|
|
302
|
+
for (const context of contexts)
|
|
303
|
+
if (context.snapshot && context.spec)
|
|
304
|
+
for (const proposal of generateFixCandidates(context.snapshot, context.spec, input.limit)) {
|
|
305
|
+
const key = JSON.stringify(proposal.mutations);
|
|
306
|
+
if (!proposals.has(key))
|
|
307
|
+
proposals.set(key, proposal);
|
|
308
|
+
}
|
|
309
|
+
if (nativeBaseline?.status === 'captured' && nativeBaseline.snapshot)
|
|
310
|
+
for (const proposal of generateFixCandidates(nativeBaseline.snapshot, primarySpec, input.limit)) {
|
|
311
|
+
const key = JSON.stringify(proposal.mutations);
|
|
312
|
+
if (!proposals.has(key))
|
|
313
|
+
proposals.set(key, proposal);
|
|
314
|
+
}
|
|
315
|
+
for (const candidate of [...proposals.values()].slice(0, input.limit)) {
|
|
316
|
+
const evidence = [];
|
|
317
|
+
for (const context of contexts) {
|
|
318
|
+
try {
|
|
319
|
+
if (context.error)
|
|
320
|
+
throw new BridgeFault(context.error.code, context.error.message);
|
|
321
|
+
const port = context.port;
|
|
322
|
+
if (candidate.mutations.length > 1 && !port.trialBatch)
|
|
323
|
+
throw new BridgeFault('UNSUPPORTED_EDIT', 'Browser adapter does not support a batch candidate');
|
|
324
|
+
const overrides = context.id === 'baseline' ? s.baselineOverrides : undefined;
|
|
325
|
+
const snapshot = port.trialBatch ? await port.trialBatch(candidate.mutations, overrides) : await port.trial(candidate.mutations[0].elementId, candidate.mutations[0].patch, overrides);
|
|
326
|
+
await this.assertRevisions(s, snapshot);
|
|
327
|
+
const results = [...evaluateConstraints(snapshot, context.spec)], passes = new Set(evaluateConstraints(context.snapshot, context.spec).filter(r => r.status === 'pass').map(r => r.constraintId));
|
|
328
|
+
evidence.push({ profileId: context.id, platform: 'web', profile: snapshot.profile, scenarioState: context.scenarioState, snapshotId: snapshot.snapshotId, status: resultStatus(results), results, regressions: results.filter(r => r.status === 'fail' && passes.has(r.constraintId)).length, warnings: snapshot.webDiagnostics?.warnings.filter(w => w.severity === 'confirmed').length ?? 0 });
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
if (error instanceof BridgeFault && ['STALE_SOURCE', 'STALE_SNAPSHOT', 'SESSION_NOT_FOUND'].includes(error.code))
|
|
332
|
+
throw error;
|
|
333
|
+
const evidenceError = errorValue(error);
|
|
334
|
+
evidence.push({ profileId: context.id, platform: 'web', profile: context.profile, scenarioState: context.scenarioState, status: unavailableProfile(evidenceError), results: [], regressions: 0, warnings: 0, error: evidenceError });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const primary = evidence[0];
|
|
338
|
+
candidates.push({ ...candidate, results: primary.results, warnings: primary.warnings, score: 0, profiles: evidence, ...(primary.error ? { error: primary.error } : {}) });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
try {
|
|
343
|
+
await s.browser.resetTrial();
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
await Promise.allSettled(opened.map(port => port.close()));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const restored = await this.capture(s, s.baselineOverrides);
|
|
350
|
+
if (fingerprint(restored) !== fingerprint(baseline) || JSON.stringify(restored.nodes.map(n => n.rect)) !== JSON.stringify(baseline.nodes.map(n => n.rect))) {
|
|
351
|
+
s.baseline = undefined;
|
|
352
|
+
throw new BridgeFault('STALE_SNAPSHOT', 'Candidate reset changed the baseline');
|
|
353
|
+
}
|
|
354
|
+
const unavailable = (candidate) => candidate.profiles?.filter(p => p.status === 'unavailable' || p.status === 'unsupported').length ?? 0;
|
|
355
|
+
const regressions = (candidate) => candidate.profiles?.reduce((sum, p) => sum + p.regressions, 0) ?? 0;
|
|
356
|
+
const warnings = (candidate) => candidate.profiles?.reduce((sum, p) => sum + p.warnings, 0) ?? 0;
|
|
357
|
+
const remaining = (candidate) => candidate.profiles?.reduce((sum, p) => sum + p.results.filter(result => result.status === 'fail').length, 0) ?? 0;
|
|
358
|
+
const editSize = (candidate) => candidate.mutations.reduce((count, mutation) => count + Object.keys(mutation.patch).length, 0);
|
|
359
|
+
candidates.sort((a, b) => Number('error' in a) - Number('error' in b) || unavailable(a) - unavailable(b) || regressions(a) - regressions(b) || warnings(a) - warnings(b) || remaining(a) - remaining(b) || editSize(a) - editSize(b));
|
|
360
|
+
const result = { candidates: candidates.map((candidate, index) => ({ ...candidate, score: index + 1 })), baselineSnapshotId: input.baselineSnapshotId, baselines, ...(input.includeNativeEvidence ? { nativeBaseline } : {}) };
|
|
361
|
+
await this.artifact(s.id, 'suggestions.json', JSON.stringify(result, null, 2));
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
async open(input) {
|
|
365
|
+
if ((input.nativePort === undefined) !== (input.nativeToken === undefined))
|
|
366
|
+
throw new BridgeFault('INVALID_INPUT', 'nativePort and nativeToken must be supplied together');
|
|
367
|
+
const id = randomUUID(), token = input.nativeToken ?? randomUUID(), port = input.nativePort ?? 19482;
|
|
368
|
+
const source = this.deps.source(input.projectRoot);
|
|
369
|
+
const native = await this.deps.native({ port, token, scenarioId: input.scenarioId, sessionId: id, targetId: input.nativeTarget?.targetId, platform: input.nativeTarget?.platform });
|
|
370
|
+
let browser;
|
|
371
|
+
try {
|
|
372
|
+
browser = await this.deps.browser({ ...input, sessionId: id });
|
|
373
|
+
}
|
|
374
|
+
catch (e) {
|
|
375
|
+
await native.close();
|
|
376
|
+
throw e;
|
|
377
|
+
}
|
|
378
|
+
let nativeScenario, nativeProfiles;
|
|
379
|
+
if (input.nativeScenario) {
|
|
380
|
+
if (!input.nativeTarget) {
|
|
381
|
+
await Promise.allSettled([browser.close(), native.close()]);
|
|
382
|
+
throw new BridgeFault('INVALID_INPUT', 'nativeScenario requires nativeTarget');
|
|
383
|
+
}
|
|
384
|
+
const options = { ...input.nativeScenario, target: input.nativeTarget, projectRoot: input.projectRoot, runHook: native.runHook?.bind(native), readProfile: async () => (await native.capture({})).profile };
|
|
385
|
+
nativeScenario = createNativeScenarioPort(options);
|
|
386
|
+
nativeProfiles = createNativeProfilePort(options);
|
|
387
|
+
try {
|
|
388
|
+
await nativeProfiles.recover();
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
await Promise.allSettled([browser.close(), native.close(), nativeScenario.close()]);
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
this.sessions.set(id, { id, webUrl: input.webUrl, browser, native, source, scenarioId: input.scenarioId, profile: input.profile, snapshots: new Map(), commits: new Map(), busy: false, nativeTarget: input.nativeTarget, nativeScenario, nativeProfiles });
|
|
396
|
+
return { sessionId: id, native: { port, token }, capabilities: { trial: 'rn-style-props', sourceEdits: 'scoped-transactions', batchTrials: Boolean(browser.trialBatch), nativePlatforms: ['android', 'ios'], nativeScenario: Boolean(nativeScenario), nativeVerification: 'required' } };
|
|
397
|
+
}
|
|
398
|
+
spec(s) { if (!s.spec)
|
|
399
|
+
throw new BridgeFault('INVALID_INPUT', 'Load a wireframe specification first'); return s.spec; }
|
|
400
|
+
async capture(s, profileOverrides) {
|
|
401
|
+
const snapshot = await s.browser.capture(profileOverrides);
|
|
402
|
+
const expectedWindow = profileOverrides?.orientation && profileOverrides.orientation !== s.profile.orientation ?
|
|
403
|
+
{ width: s.profile.window.height, height: s.profile.window.width } : s.profile.window;
|
|
404
|
+
const dimensionsMatch = Math.abs(snapshot.profile.window.width - expectedWindow.width) <= 0.01 && Math.abs(snapshot.profile.window.height - expectedWindow.height) <= 0.01;
|
|
405
|
+
if (snapshot.scenarioId !== s.scenarioId || (!profileOverrides && !profilesMatch(snapshot.profile, s.profile)) ||
|
|
406
|
+
(profileOverrides?.orientation && snapshot.profile.orientation !== profileOverrides.orientation) ||
|
|
407
|
+
(profileOverrides?.fontScale !== undefined && snapshot.profile.fontScale !== profileOverrides.fontScale) || !dimensionsMatch)
|
|
408
|
+
throw new BridgeFault('STALE_SNAPSHOT', 'Runtime context differs from the requested session profile');
|
|
409
|
+
if (snapshot.status !== 'stable')
|
|
410
|
+
throw new BridgeFault('UNSTABLE_LAYOUT', 'Layout did not settle');
|
|
411
|
+
remember(s, snapshot);
|
|
412
|
+
return snapshot;
|
|
413
|
+
}
|
|
414
|
+
async assertRevisions(s, snapshot) {
|
|
415
|
+
for (const [file, hash] of Object.entries(snapshot.revisions))
|
|
416
|
+
if ((await s.source.read(file)).hash !== hash)
|
|
417
|
+
throw new BridgeFault('STALE_SOURCE', `Source ${file} changed after capture`);
|
|
418
|
+
}
|
|
419
|
+
async commit(s, input) {
|
|
420
|
+
const replay = [...s.commits.values()].find(c => c.trialId === input.trialId);
|
|
421
|
+
if (replay) {
|
|
422
|
+
const files = replay.receipt.files ?? [replay.receipt];
|
|
423
|
+
for (const file of files) {
|
|
424
|
+
const expected = input.expectedRevisions?.[file.path] ?? (files.length === 1 ? input.expectedSourceHash : undefined);
|
|
425
|
+
if (expected !== file.oldHash)
|
|
426
|
+
throw new BridgeFault('STALE_SOURCE', 'Replay source hash differs', { path: file.path });
|
|
427
|
+
if ((await s.source.read(file.path)).hash !== file.newHash)
|
|
428
|
+
throw new BridgeFault('STALE_SOURCE', 'Committed source changed', { path: file.path });
|
|
429
|
+
}
|
|
430
|
+
if (input.mode === 'preview')
|
|
431
|
+
return { plan: replay.plan, transactionPlan: replay.transactionPlan };
|
|
432
|
+
if (replay.pendingNativeConstraints.length && !input.allowNativePending)
|
|
433
|
+
throw new BridgeFault('VERIFICATION_FAILED', 'Replay requires explicit allowNativePending for its deferred text-fit constraints');
|
|
434
|
+
if (!replay.web)
|
|
435
|
+
replay.web = await this.captureCommitted(s, replay.receipt);
|
|
436
|
+
const pending = replay.web ? pendingInteractions(replay.web, replay.interactions) : replay.interactions;
|
|
437
|
+
const webStatus = pending.length ? 'interaction-pending' : replay.pendingNativeConstraints.length ? 'native-checks-pending' : undefined;
|
|
438
|
+
return { receipt: replay.receipt, snapshot: replay.web, ...(replay.pendingNativeConstraints.length ? { pendingNativeConstraints: replay.pendingNativeConstraints } : {}), ...(pending.length ? { pendingInteractions: pending } : {}), ...(webStatus ? { webStatus } : {}) };
|
|
439
|
+
}
|
|
440
|
+
const trial = s.trial;
|
|
441
|
+
if (!trial || trial.trialId !== input.trialId || !s.baseline)
|
|
442
|
+
throw new BridgeFault('TRIAL_NOT_FOUND', 'Trial is no longer active');
|
|
443
|
+
const trialSpec = s.baselineSpec ?? this.spec(s), trialIds = trial.results.map(result => result.constraintId), allExpectedIds = [...trialSpec.requiredElementIds.map(id => `required:${id}`), ...trialSpec.constraints.map(rule => rule.id)], expectedIds = [...trialSpec.requiredElementIds.filter(id => !trial.snapshot.nodes.some(node => node.id === id)).map(id => `required:${id}`), ...trialSpec.constraints.map(rule => rule.id)];
|
|
444
|
+
const deferred = trial.results.filter(result => result.status === 'unavailable');
|
|
445
|
+
const pendingNativeConstraints = [];
|
|
446
|
+
if (!allPass(trial.results)) {
|
|
447
|
+
const nodes = new Map(trial.snapshot.nodes.map(node => [node.id, node]));
|
|
448
|
+
const validDeferred = input.allowNativePending && trial.snapshot.platform === 'web' && trial.snapshot.status === 'stable' && trial.snapshot.scenarioId === s.scenarioId && profilesMatch(trial.snapshot.profile, s.profile) && new Set(trial.snapshot.nodes.map(node => node.id)).size === trial.snapshot.nodes.length && new Set(allExpectedIds).size === allExpectedIds.length && new Set(trialIds).size === trialIds.length && expectedIds.length === trialIds.length && expectedIds.every(id => trialIds.includes(id)) && trialSpec.requiredElementIds.every(id => nodes.has(id)) && deferred.length > 0 && deferred.every(result => { const rule = trialSpec.constraints.find(item => item.id === result.constraintId), node = rule?.type === 'textFits' ? nodes.get(rule.elementId) : undefined; return rule?.type === 'textFits' && !!node && result.elementIds.length === 1 && result.elementIds[0] === rule.elementId && (!node.textFit || node.textFit.status === 'unavailable'); }) && trial.results.every(result => result.status === 'pass' || deferred.includes(result));
|
|
449
|
+
if (!validDeferred)
|
|
450
|
+
throw new BridgeFault('VERIFICATION_FAILED', 'Trial must pass every required constraint; only valid web text-fit evidence can be deferred to native');
|
|
451
|
+
pendingNativeConstraints.push(...deferred.map(result => result.constraintId));
|
|
452
|
+
}
|
|
453
|
+
if (input.mode === 'apply' && s.baseline && !profilesMatch(s.baseline.profile, s.profile))
|
|
454
|
+
throw new BridgeFault('UNSUPPORTED_PROFILE', 'Trials captured with profile overrides can be previewed but not committed for Android acceptance');
|
|
455
|
+
if (input.mode === 'apply' && trial.snapshot.webDiagnostics?.warnings.some(w => w.severity === 'confirmed'))
|
|
456
|
+
throw new BridgeFault('VERIFICATION_FAILED', 'Confirmed Chromium diagnostics must be resolved before applying this trial');
|
|
457
|
+
const current = await s.browser.capture(s.baselineOverrides);
|
|
458
|
+
if (input.mode === 'apply' && current.webDiagnostics?.warnings.some(w => w.severity === 'confirmed'))
|
|
459
|
+
throw new BridgeFault('VERIFICATION_FAILED', 'Confirmed Chromium diagnostics must be resolved before applying this trial');
|
|
460
|
+
if (fingerprint(current) !== fingerprint(trial.snapshot) || JSON.stringify(current.nodes.map(n => n.rect)) !== JSON.stringify(trial.snapshot.nodes.map(n => n.rect)))
|
|
461
|
+
throw new BridgeFault('STALE_SNAPSHOT', 'Trial layout changed before commit');
|
|
462
|
+
await this.assertRevisions(s, s.baseline);
|
|
463
|
+
const mutations = trial.mutations ?? [{ elementId: trial.elementId, patch: trial.patch }];
|
|
464
|
+
const source = s.source;
|
|
465
|
+
const sourceMutations = mutations.map(m => { const node = trial.snapshot.nodes.find(n => n.id === m.elementId); if (!node?.source)
|
|
466
|
+
throw new BridgeFault('UNSUPPORTED_EDIT', 'Source metadata missing'); if (node.editReason === 'Dynamic style expression')
|
|
467
|
+
throw new BridgeFault('UNSUPPORTED_EDIT', 'Style callbacks are not source-editable'); if (node.source.styleForm === 'pressable-state') {
|
|
468
|
+
if (input.scope?.kind !== 'pressable-state' || !input.scope.state)
|
|
469
|
+
throw new BridgeFault('UNSUPPORTED_EDIT', 'A supported callback requires explicit pressable-state scope and state');
|
|
470
|
+
const active = current.nodes.find(n => n.id === m.elementId)?.pressableState;
|
|
471
|
+
if (node.pressableState !== input.scope.state || active !== input.scope.state)
|
|
472
|
+
throw new BridgeFault('STALE_SNAPSHOT', 'Requested Pressable state is not the currently measured trial state');
|
|
473
|
+
} if (input.expectedRevisions?.[node.source.path] !== undefined && input.expectedRevisions[node.source.path] !== node.source.hash)
|
|
474
|
+
throw new BridgeFault('STALE_SOURCE', 'Expected revision differs'); if (input.expectedSourceHash && input.expectedSourceHash !== node.source.hash)
|
|
475
|
+
throw new BridgeFault('STALE_SOURCE', 'Expected hash differs'); const instances = trial.snapshot.nodes.filter(n => n.source?.path === node.source.path && n.source.start === node.source.start); if (instances.length > 1 && !input.scope?.acknowledgeInstances)
|
|
476
|
+
throw new BridgeFault('UNSUPPORTED_EDIT', 'Repeated site requires acknowledgment'); return { source: node.source, patch: m.patch, scope: input.scope ?? { kind: 'local' } }; });
|
|
477
|
+
if (!input.expectedSourceHash && !input.expectedRevisions)
|
|
478
|
+
throw new BridgeFault('INVALID_INPUT', 'Expected source revisions are required');
|
|
479
|
+
const files = source.readProject ? await source.readProject() : await Promise.all([...new Set(sourceMutations.map(m => m.source.path))].map(p => source.read(p)));
|
|
480
|
+
const planned = planTransaction(files, sourceMutations);
|
|
481
|
+
if (!planned.ok)
|
|
482
|
+
throw new BridgeFault(planned.error.code, planned.error.message, planned.error.details);
|
|
483
|
+
if (input.expectedSourceHash && !input.expectedRevisions && planned.value.files.some(file => !sourceMutations.some(mutation => mutation.source.path === file.source.path)))
|
|
484
|
+
throw new BridgeFault('INVALID_INPUT', 'An imported style edit changes another file; supply expectedRevisions for every changed file');
|
|
485
|
+
planned.value.intentId = trial.trialId;
|
|
486
|
+
for (const consumer of planned.value.blastRadius.consumers) {
|
|
487
|
+
consumer.mountedIds = trial.snapshot.nodes.filter(n => n.source?.path === consumer.source.path && n.source.start === consumer.source.start).map(n => n.id);
|
|
488
|
+
consumer.requiredScenarios = consumer.mountedIds.length ? [s.scenarioId] : [];
|
|
489
|
+
}
|
|
490
|
+
if (input.mode === 'preview')
|
|
491
|
+
return { plan: planned.value.files[0], transactionPlan: planned.value };
|
|
492
|
+
if (input.scope?.kind === 'shared' && planned.value.blastRadius.consumers.some(consumer => consumer.mountedIds.length === 0))
|
|
493
|
+
throw new BridgeFault('VERIFICATION_FAILED', 'Shared edits require measured coverage for every discovered consumer', { blastRadius: planned.value.blastRadius });
|
|
494
|
+
for (const file of planned.value.files)
|
|
495
|
+
if ((input.expectedRevisions?.[file.source.path] ?? (planned.value.files.length === 1 ? input.expectedSourceHash : undefined)) !== file.expectedHash)
|
|
496
|
+
throw new BridgeFault('STALE_SOURCE', 'Apply requires expected hashes for every changed file', { path: file.source.path });
|
|
497
|
+
if (!source.applyTransaction)
|
|
498
|
+
throw new BridgeFault('UNSUPPORTED_EDIT', 'Source adapter lacks transaction support');
|
|
499
|
+
const tx = await source.applyTransaction(planned.value);
|
|
500
|
+
const receipt = { ...tx, ...tx.files[0], diff: tx.files.map(f => f.diff).join('\n') };
|
|
501
|
+
const interactions = mutations.flatMap(m => trial.snapshot.nodes.find(n => n.id === m.elementId)?.source?.styleForm === 'pressable-state' ? [{ elementId: m.elementId, pressed: input.scope?.state === 'pressed' }] : []);
|
|
502
|
+
const entry = { receipt, web: null, plan: planned.value.files[0], transactionPlan: planned.value, trialId: trial.trialId, spec: structuredClone(trialSpec), pendingNativeConstraints, interactions };
|
|
503
|
+
s.commits.set(receipt.commitId, entry);
|
|
504
|
+
s.trial = undefined;
|
|
505
|
+
s.baseline = undefined;
|
|
506
|
+
// A source write can succeed even when HMR fails. Preserve and return its receipt.
|
|
507
|
+
try {
|
|
508
|
+
await this.artifact(s.id, `${receipt.commitId}.diff`, receipt.diff);
|
|
509
|
+
entry.web = await this.captureCommitted(s, receipt);
|
|
510
|
+
if (!entry.web)
|
|
511
|
+
return { receipt, webStatus: 'reload-timeout', nativeStatus: 'pending' };
|
|
512
|
+
const results = evaluateConstraints(entry.web, entry.spec), pending = pendingInteractions(entry.web, entry.interactions);
|
|
513
|
+
await this.artifact(s.id, `${receipt.commitId}.web.json`, JSON.stringify({ receipt, snapshot: entry.web, results }, null, 2));
|
|
514
|
+
const onlyDeferredUnavailable = results.every(result => result.status === 'pass' || (result.status === 'unavailable' && pendingNativeConstraints.includes(result.constraintId)));
|
|
515
|
+
const webStatus = !onlyDeferredUnavailable ? 'failed' : pending.length ? 'interaction-pending' : pendingNativeConstraints.length ? 'native-checks-pending' : allPass(results) ? 'passed' : 'failed';
|
|
516
|
+
return { receipt, snapshot: entry.web, results, webStatus, ...(pendingNativeConstraints.length ? { pendingNativeConstraints } : {}), ...(pending.length ? { pendingInteractions: pending } : {}) };
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
const failure = { receipt, webStatus: 'capture-failed', message: e instanceof Error ? e.message : String(e) };
|
|
520
|
+
await this.artifact(s.id, receipt.commitId + '.web-failure.json', JSON.stringify(failure, null, 2)).catch(() => undefined);
|
|
521
|
+
return failure;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
async verify(s, commitId) {
|
|
525
|
+
const entry = s.commits.get(commitId);
|
|
526
|
+
if (!entry)
|
|
527
|
+
throw new BridgeFault('INVALID_INPUT', 'Unknown commit');
|
|
528
|
+
let currentWeb = null;
|
|
529
|
+
try {
|
|
530
|
+
currentWeb = await this.capture(s);
|
|
531
|
+
}
|
|
532
|
+
catch { /* A committed reload may still be settling; retry through the bounded reload waiter below. */ }
|
|
533
|
+
const files = entry.receipt.files ?? [entry.receipt];
|
|
534
|
+
if (currentWeb && files.every(file => currentWeb.revisions[file.path] === file.newHash))
|
|
535
|
+
entry.web = currentWeb;
|
|
536
|
+
else
|
|
537
|
+
entry.web = await this.captureCommitted(s, entry.receipt);
|
|
538
|
+
if (!entry.web)
|
|
539
|
+
throw new BridgeFault('VERIFICATION_FAILED', 'Committed web result has not been captured; retry after reload');
|
|
540
|
+
await this.assertRevisions(s, entry.web);
|
|
541
|
+
const snapshot = await s.native.capture(entry.web.revisions);
|
|
542
|
+
const results = [...evaluateConstraints(snapshot, entry.spec)];
|
|
543
|
+
const sameSources = Object.entries(entry.web.revisions).every(([p, h]) => snapshot.revisions[p] === h);
|
|
544
|
+
const sameElements = entry.web.nodes.length === snapshot.nodes.length && entry.web.nodes.every(n => snapshot.nodes.some(other => n.id === other.id));
|
|
545
|
+
const webResults = evaluateConstraints(entry.web, entry.spec), webRequiredPass = webResults.every(result => result.status === 'pass' || (result.status === 'unavailable' && entry.pendingNativeConstraints.includes(result.constraintId)));
|
|
546
|
+
const pendingWebInteractions = pendingInteractions(entry.web, entry.interactions), pendingNativeInteractions = pendingInteractions(snapshot, entry.interactions);
|
|
547
|
+
for (const requirement of entry.interactions)
|
|
548
|
+
if (pendingWebInteractions.some(item => item.elementId === requirement.elementId) || pendingNativeInteractions.some(item => item.elementId === requirement.elementId))
|
|
549
|
+
results.push({ constraintId: `interaction:${requirement.elementId}`, status: 'unavailable', elementIds: [requirement.elementId], deviation: null, tolerance: 0, message: `Required ${requirement.pressed ? 'pressed' : 'rest'} interaction state was not measured on both web and native` });
|
|
550
|
+
const targetMatches = snapshot.platform === (s.nativeTarget?.platform ?? 'android') && (!s.nativeTarget || snapshot.nativeTarget?.targetId === s.nativeTarget.targetId);
|
|
551
|
+
const passed = targetMatches && snapshot.status === 'stable' && snapshot.scenarioId === s.scenarioId && snapshot.sessionId === s.id && sameSources && sameElements && allPass(results) && webRequiredPass && !pendingWebInteractions.length && !pendingNativeInteractions.length;
|
|
552
|
+
entry.receipt.nativeStatus = passed ? 'passed' : results.some(result => result.status === 'fail') ? 'failed' : 'pending';
|
|
553
|
+
const result = { receipt: entry.receipt, snapshot, results, delta: diffSnapshots(entry.web, snapshot), passed };
|
|
554
|
+
await this.artifact(s.id, `${commitId}.native.json`, JSON.stringify(result, null, 2));
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
async captureCommitted(s, receipt) {
|
|
558
|
+
const files = receipt.files ?? [receipt];
|
|
559
|
+
const assertFiles = async () => { for (const file of files)
|
|
560
|
+
if ((await s.source.read(file.path)).hash !== file.newHash)
|
|
561
|
+
throw new BridgeFault('STALE_SOURCE', 'Committed source changed before reload verification'); };
|
|
562
|
+
await assertFiles();
|
|
563
|
+
await s.browser.resetTrial();
|
|
564
|
+
const deadline = Date.now() + 20_000;
|
|
565
|
+
let lastError;
|
|
566
|
+
do {
|
|
567
|
+
await assertFiles();
|
|
568
|
+
try {
|
|
569
|
+
const snapshot = await this.capture(s);
|
|
570
|
+
if (files.every(file => snapshot.revisions[file.path] === file.newHash))
|
|
571
|
+
return snapshot;
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
if (!(error instanceof BridgeFault) || !['STALE_SOURCE', 'STALE_SNAPSHOT', 'MISSING_ELEMENT', 'UNSTABLE_LAYOUT', 'TIMEOUT'].includes(error.code))
|
|
575
|
+
throw error;
|
|
576
|
+
lastError = error;
|
|
577
|
+
}
|
|
578
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
579
|
+
} while (Date.now() < deadline);
|
|
580
|
+
await this.artifact(s.id, receipt.commitId + '.reload-timeout.json', JSON.stringify({ receipt, error: lastError instanceof Error ? lastError.message : 'Committed revisions did not arrive' }, null, 2));
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
async artifact(sessionId, name, data) {
|
|
584
|
+
const dir = path.join(this.deps.artifactsRoot, sessionId);
|
|
585
|
+
await mkdir(dir, { recursive: true });
|
|
586
|
+
const file = path.join(dir, name.replace(/[^a-zA-Z0-9_.-]/g, '_'));
|
|
587
|
+
await writeFile(file, data);
|
|
588
|
+
return file;
|
|
589
|
+
}
|
|
590
|
+
async close() { const sessions = [...this.sessions.values()]; this.sessions.clear(); await Promise.allSettled(sessions.flatMap(s => [s.browser.close(), s.native.close(), ...(s.nativeScenario ? [s.nativeScenario.close()] : [])])); }
|
|
591
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CDPSession, Page } from 'playwright';
|
|
2
|
+
import { type SpatialSnapshot } from '@nativemason/contracts';
|
|
3
|
+
/** Read-only, bounded browser evidence; native semantics are never inferred here. */
|
|
4
|
+
export declare function captureAccessibilityEvidence(page: Page, cdp: CDPSession, snapshot: SpatialSnapshot): Promise<SpatialSnapshot>;
|