nativemason 0.0.0-stage → 0.3.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.
Files changed (44) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +4 -2
  3. package/bin/nativemason.js +13 -0
  4. package/dist/application/index.d.ts +34 -0
  5. package/dist/application/index.js +593 -0
  6. package/dist/browser/accessibility.d.ts +4 -0
  7. package/dist/browser/accessibility.js +93 -0
  8. package/dist/browser/index.d.ts +16 -0
  9. package/dist/browser/index.js +495 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +23 -0
  12. package/dist/core/accessibility.d.ts +5 -0
  13. package/dist/core/accessibility.js +55 -0
  14. package/dist/core/design-intent.d.ts +3 -0
  15. package/dist/core/design-intent.js +108 -0
  16. package/dist/core/index.d.ts +15 -0
  17. package/dist/core/index.js +324 -0
  18. package/dist/editor/index.d.ts +4 -0
  19. package/dist/editor/index.js +4 -0
  20. package/dist/editor/planner.d.ts +4 -0
  21. package/dist/editor/planner.js +374 -0
  22. package/dist/editor/source-format.d.ts +6 -0
  23. package/dist/editor/source-format.js +32 -0
  24. package/dist/editor/source-port.d.ts +3 -0
  25. package/dist/editor/source-port.js +79 -0
  26. package/dist/editor/transaction-planner.d.ts +3 -0
  27. package/dist/editor/transaction-planner.js +231 -0
  28. package/dist/editor/transaction-port.d.ts +3 -0
  29. package/dist/editor/transaction-port.js +257 -0
  30. package/dist/evidence/report.d.ts +4 -0
  31. package/dist/evidence/report.js +180 -0
  32. package/dist/init/index.d.ts +26 -0
  33. package/dist/init/index.js +81 -0
  34. package/dist/instrumentation/index.d.ts +25 -0
  35. package/dist/instrumentation/index.js +235 -0
  36. package/dist/mcp/index.d.ts +3 -0
  37. package/dist/mcp/index.js +42 -0
  38. package/dist/native/index.d.ts +4 -0
  39. package/dist/native/index.js +221 -0
  40. package/dist/overlay/index.d.ts +3 -0
  41. package/dist/overlay/index.js +35 -0
  42. package/dist/scenario/index.d.ts +5 -0
  43. package/dist/scenario/index.js +318 -0
  44. package/package.json +46 -3
@@ -0,0 +1,221 @@
1
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { WebSocketServer, WebSocket } from 'ws';
3
+ import { BridgeFault, NativeBindHostSchema, NativeClientMessageSchema } from '@nativemason/contracts';
4
+ export async function createNativeBridge(options) {
5
+ const host = NativeBindHostSchema.safeParse(options.host ?? '127.0.0.1');
6
+ if (!host.success)
7
+ throw new BridgeFault('INVALID_INPUT', 'Native host must be a loopback or private LAN IPv4 interface address');
8
+ const server = new WebSocketServer({ host: host.data, port: options.port, maxPayload: 4 * 1024 * 1024 });
9
+ await new Promise((resolve, reject) => { server.once('listening', resolve); server.once('error', reject); });
10
+ let client;
11
+ let closed = false;
12
+ let connectedPlatform = "android";
13
+ let connectedTargetId;
14
+ let supportsActions = false;
15
+ let handshakeFailure;
16
+ const actions = new Map();
17
+ const timeoutMs = options.timeoutMs ?? 10_000;
18
+ const pending = new Map();
19
+ const waiters = new Set();
20
+ function failPending(error) {
21
+ for (const item of pending.values()) {
22
+ clearTimeout(item.timer);
23
+ item.reject(error);
24
+ }
25
+ pending.clear();
26
+ for (const item of actions.values()) {
27
+ clearTimeout(item.timer);
28
+ item.reject(error);
29
+ }
30
+ actions.clear();
31
+ }
32
+ server.on('connection', socket => {
33
+ let authenticated = false;
34
+ const helloTimeout = setTimeout(() => socket.close(1008, 'Handshake timeout'), timeoutMs);
35
+ socket.on('message', raw => {
36
+ let decoded;
37
+ try {
38
+ decoded = JSON.parse(raw.toString());
39
+ }
40
+ catch {
41
+ socket.close(1008, 'Invalid protocol message');
42
+ return;
43
+ }
44
+ const parsed = NativeClientMessageSchema.safeParse(decoded);
45
+ if (!parsed.success) {
46
+ if (typeof decoded === 'object' && decoded !== null && 'type' in decoded && decoded.type === 'hello')
47
+ handshakeFailure = 'Native handshake schema or protocol version is incompatible; rebuild the development runtime';
48
+ // Only correlate validated-schema coherence failures after authentication
49
+ // to an already pending request. Other malformed traffic closes the peer.
50
+ const requestId = typeof decoded === 'object' && decoded !== null && 'requestId' in decoded && typeof decoded.requestId === 'string' ? decoded.requestId : undefined;
51
+ const staleSource = parsed.error.issues.some(issue => issue.code === 'custom' && issue.params?.bridgeCode === 'STALE_SOURCE');
52
+ const item = authenticated && client === socket && requestId ? pending.get(requestId) : undefined;
53
+ if (staleSource && item && requestId) {
54
+ pending.delete(requestId);
55
+ clearTimeout(item.timer);
56
+ item.reject(new BridgeFault('STALE_SOURCE', 'Native snapshot contains inconsistent source metadata'));
57
+ return;
58
+ }
59
+ socket.close(1008, 'Invalid protocol message');
60
+ return;
61
+ }
62
+ const message = parsed.data;
63
+ if (message.type === 'hello') {
64
+ if (message.protocolVersion === 1 && (options.platform === 'ios' || options.targetId)) {
65
+ handshakeFailure = 'Protocol v2 required for selected native target';
66
+ socket.close(1002, handshakeFailure);
67
+ return;
68
+ }
69
+ if (message.protocolVersion === 2 && (!message.platform || !message.targetId || !message.capabilities)) {
70
+ handshakeFailure = 'Protocol v2 target identity and capabilities required';
71
+ socket.close(1002, handshakeFailure);
72
+ return;
73
+ }
74
+ if ((options.platform && options.platform !== message.platform) || (options.targetId && options.targetId !== message.targetId)) {
75
+ handshakeFailure = 'Native target mismatch';
76
+ socket.close(1008, handshakeFailure);
77
+ return;
78
+ }
79
+ const received = Buffer.from(message.token);
80
+ const expected = Buffer.from(options.token);
81
+ if (authenticated || received.length !== expected.length || !timingSafeEqual(received, expected) || message.scenarioId !== options.scenarioId) {
82
+ socket.close(1008, 'Handshake rejected');
83
+ return;
84
+ }
85
+ clearTimeout(helloTimeout);
86
+ authenticated = true;
87
+ handshakeFailure = undefined;
88
+ connectedPlatform = message.platform ?? 'android';
89
+ connectedTargetId = message.targetId;
90
+ supportsActions = message.capabilities?.scenarioActions ?? false;
91
+ if (client && client !== socket) {
92
+ failPending(new BridgeFault('NATIVE_DISCONNECTED', 'Native runtime reconnected'));
93
+ client.close(1000, 'Replaced by reconnect');
94
+ }
95
+ client = socket;
96
+ for (const waiter of waiters)
97
+ waiter.resolve(socket);
98
+ waiters.clear();
99
+ return;
100
+ }
101
+ if (!authenticated || client !== socket) {
102
+ socket.close(1008, 'Authenticate first');
103
+ return;
104
+ }
105
+ const action = actions.get(message.requestId);
106
+ if (action) {
107
+ clearTimeout(action.timer);
108
+ actions.delete(message.requestId);
109
+ if (message.type === 'action_result')
110
+ action.resolve();
111
+ else if (message.type === 'error')
112
+ action.reject(new BridgeFault(message.error.code, message.error.message, message.error.details));
113
+ else
114
+ action.reject(new BridgeFault('VERIFICATION_FAILED', 'Expected native action result'));
115
+ return;
116
+ }
117
+ if (message.type === 'action_result')
118
+ return;
119
+ const item = pending.get(message.requestId);
120
+ if (!item)
121
+ return;
122
+ pending.delete(message.requestId);
123
+ clearTimeout(item.timer);
124
+ if (message.type === 'error') {
125
+ item.reject(new BridgeFault(message.error.code, message.error.message, message.error.details));
126
+ return;
127
+ }
128
+ const snapshot = message.snapshot;
129
+ if (snapshot.platform !== connectedPlatform || snapshot.sessionId !== options.sessionId || snapshot.scenarioId !== options.scenarioId) {
130
+ item.reject(new BridgeFault('VERIFICATION_FAILED', 'Native snapshot identity mismatch'));
131
+ return;
132
+ }
133
+ if (options.targetId && (snapshot.nativeTarget?.targetId !== connectedTargetId || snapshot.nativeTarget?.platform !== connectedPlatform)) {
134
+ item.reject(new BridgeFault('VERIFICATION_FAILED', 'Native snapshot target mismatch'));
135
+ return;
136
+ }
137
+ if (snapshot.status !== 'stable') {
138
+ item.reject(new BridgeFault('UNSTABLE_LAYOUT', 'Native layout did not settle'));
139
+ return;
140
+ }
141
+ for (const [path, hash] of Object.entries(item.expected)) {
142
+ if (snapshot.revisions[path] !== hash) {
143
+ item.reject(new BridgeFault('STALE_SOURCE', `Native source mismatch for ${path}`));
144
+ return;
145
+ }
146
+ }
147
+ item.resolve(snapshot);
148
+ });
149
+ socket.on('error', () => { });
150
+ socket.on('close', () => {
151
+ clearTimeout(helloTimeout);
152
+ if (client === socket) {
153
+ client = undefined;
154
+ failPending(new BridgeFault('NATIVE_DISCONNECTED', 'Native runtime disconnected'));
155
+ }
156
+ });
157
+ });
158
+ async function connected() {
159
+ if (closed)
160
+ throw new BridgeFault('NATIVE_DISCONNECTED', 'Native bridge is closed');
161
+ if (client?.readyState === WebSocket.OPEN)
162
+ return client;
163
+ return new Promise((resolve, reject) => {
164
+ const waiter = { resolve: (socket) => { clearTimeout(timer); resolve(socket); }, reject: (error) => { clearTimeout(timer); reject(error); } };
165
+ const timer = setTimeout(() => { waiters.delete(waiter); reject(new BridgeFault('NATIVE_DISCONNECTED', handshakeFailure ?? 'No authenticated native runtime connected')); }, timeoutMs);
166
+ waiters.add(waiter);
167
+ });
168
+ }
169
+ return {
170
+ async runHook(name, value) {
171
+ const socket = await connected();
172
+ if (!supportsActions)
173
+ throw new BridgeFault('INVALID_INPUT', 'Native runtime does not support scenario actions');
174
+ const requestId = randomUUID();
175
+ return new Promise((resolve, reject) => {
176
+ const timer = setTimeout(() => { actions.delete(requestId); reject(new BridgeFault('TIMEOUT', 'Native action timed out')); }, timeoutMs);
177
+ actions.set(requestId, { resolve, reject, timer });
178
+ socket.send(JSON.stringify({ type: 'action', requestId, sessionId: options.sessionId, name, value }), error => {
179
+ if (error) {
180
+ clearTimeout(timer);
181
+ actions.delete(requestId);
182
+ reject(new BridgeFault('NATIVE_DISCONNECTED', error.message));
183
+ }
184
+ });
185
+ });
186
+ },
187
+ async capture(expectedRevisions) {
188
+ const socket = await connected();
189
+ if (socket.readyState !== WebSocket.OPEN)
190
+ throw new BridgeFault('NATIVE_DISCONNECTED', 'Native runtime disconnected before capture');
191
+ const requestId = randomUUID();
192
+ return new Promise((resolve, reject) => {
193
+ const timer = setTimeout(() => { pending.delete(requestId); reject(new BridgeFault('TIMEOUT', 'Native capture timed out')); }, timeoutMs);
194
+ pending.set(requestId, { resolve, reject, timer, expected: expectedRevisions });
195
+ socket.send(JSON.stringify({ type: 'capture', requestId, sessionId: options.sessionId, expectedRevisions }), error => {
196
+ if (error) {
197
+ const item = pending.get(requestId);
198
+ if (item) {
199
+ clearTimeout(item.timer);
200
+ pending.delete(requestId);
201
+ reject(new BridgeFault('NATIVE_DISCONNECTED', error.message));
202
+ }
203
+ }
204
+ });
205
+ });
206
+ },
207
+ async close() {
208
+ if (closed)
209
+ return;
210
+ closed = true;
211
+ const error = new BridgeFault('NATIVE_DISCONNECTED', 'Native bridge closed');
212
+ failPending(error);
213
+ for (const waiter of waiters)
214
+ waiter.reject(error);
215
+ waiters.clear();
216
+ for (const socket of server.clients)
217
+ socket.terminate();
218
+ await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
219
+ },
220
+ };
221
+ }
@@ -0,0 +1,3 @@
1
+ import { type OverlayScene } from '@nativemason/contracts';
2
+ /** Draws logical-coordinate scene geometry over an existing screenshot without touching page layout. */
3
+ export declare function renderOverlay(png: Uint8Array, input: OverlayScene, pixelRatio?: number): Promise<Uint8Array>;
@@ -0,0 +1,35 @@
1
+ import sharp from 'sharp';
2
+ import { BridgeFault, OverlaySceneSchema } from '@nativemason/contracts';
3
+ function xml(value) {
4
+ return value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[char]);
5
+ }
6
+ /** Draws logical-coordinate scene geometry over an existing screenshot without touching page layout. */
7
+ export async function renderOverlay(png, input, pixelRatio) {
8
+ const parsed = OverlaySceneSchema.safeParse(input);
9
+ if (!parsed.success)
10
+ throw new BridgeFault('INVALID_INPUT', 'Invalid overlay scene', parsed.error.flatten());
11
+ const scene = parsed.data;
12
+ if (!Number.isFinite(scene.width) || !Number.isFinite(scene.height) || scene.width <= 0 || scene.height <= 0)
13
+ throw new BridgeFault('INVALID_INPUT', 'Overlay dimensions must be finite and positive');
14
+ if (scene.marks.some(mark => !Number.isInteger(mark.label) || mark.label < 1))
15
+ throw new BridgeFault('INVALID_INPUT', 'Overlay marks must have positive integer labels');
16
+ const image = sharp(png);
17
+ const metadata = await image.metadata();
18
+ if (!metadata.width || !metadata.height)
19
+ throw new BridgeFault('INVALID_INPUT', 'Screenshot has no dimensions');
20
+ const scaleX = metadata.width / scene.width;
21
+ const scaleY = metadata.height / scene.height;
22
+ if (pixelRatio !== undefined && (!Number.isFinite(pixelRatio) || pixelRatio <= 0 || Math.abs(scaleX - pixelRatio) > 1 / scene.width || Math.abs(scaleY - pixelRatio) > 1 / scene.height)) {
23
+ throw new BridgeFault('INVALID_INPUT', 'Screenshot dimensions do not match the requested pixel ratio');
24
+ }
25
+ if (Math.abs(scaleX - scaleY) > Math.max(1 / scene.width, 1 / scene.height))
26
+ throw new BridgeFault('INVALID_INPUT', 'Screenshot and overlay scene have different aspect ratios');
27
+ const targets = scene.targets.map(target => `<g><title>${xml(target.id)}</title><rect x="${target.rect.x}" y="${target.rect.y}" width="${target.rect.width}" height="${target.rect.height}" fill="#22c55e" fill-opacity="0.18" stroke="#16a34a" stroke-width="1" stroke-dasharray="4 3"/></g>`).join('');
28
+ const marks = scene.marks.map(mark => {
29
+ const x = Math.min(Math.max(mark.rect.x, 0), Math.max(0, scene.width - 28));
30
+ const y = Math.min(Math.max(mark.rect.y, 0), Math.max(0, scene.height - 20));
31
+ return `<g><title>${xml(mark.id)}</title><rect x="${mark.rect.x}" y="${mark.rect.y}" width="${mark.rect.width}" height="${mark.rect.height}" fill="none" stroke="#ef4444" stroke-width="1"/><rect x="${x}" y="${y}" width="28" height="20" rx="3" fill="#b91c1c"/><text x="${x + 14}" y="${y + 14}" font-family="sans-serif" font-size="12" text-anchor="middle" fill="white">${xml(String(mark.label))}</text></g>`;
32
+ }).join('');
33
+ const svg = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${metadata.width}" height="${metadata.height}" viewBox="0 0 ${scene.width} ${scene.height}">${targets}${marks}</svg>`);
34
+ return image.composite([{ input: svg }]).png().toBuffer();
35
+ }
@@ -0,0 +1,5 @@
1
+ import { type NativeScenarioOptions, type NativeProfilePort, type ScenarioPort } from '@nativemason/contracts';
2
+ /** Native UI actions run against the explicitly selected application and device. */
3
+ export declare function createNativeScenarioPort(options: NativeScenarioOptions): ScenarioPort;
4
+ /** Journals device-wide settings before mutation and restores them even when the callback fails. */
5
+ export declare function createNativeProfilePort(options: NativeScenarioOptions): NativeProfilePort;
@@ -0,0 +1,318 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { mkdtemp, readFile, writeFile, unlink, rmdir, mkdir, open } from 'node:fs/promises';
4
+ import { tmpdir, homedir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+ import { Buffer } from 'node:buffer';
8
+ import { BridgeFault, NativeScenarioFailureEvidenceSchema, ScenarioScriptSchema } from '@nativemason/contracts';
9
+ const execute = promisify(execFile);
10
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
11
+ const regex = (value) => `^${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`;
12
+ const FAILURE_MESSAGE_LIMIT = 2048;
13
+ const FAILURE_OUTPUT_LIMIT = 16384;
14
+ function textOutput(value) {
15
+ if (typeof value === 'string')
16
+ return value;
17
+ return Buffer.isBuffer(value) ? value.toString('utf8') : '';
18
+ }
19
+ function scenarioFailureEvidence(error) {
20
+ const wrapper = error && typeof error === 'object' ? error : {};
21
+ const rawError = wrapper.reason ?? error;
22
+ const processError = (rawError && typeof rawError === 'object' ? rawError : new Error(String(rawError)));
23
+ const fullMessage = String(processError.message ?? (error instanceof Error ? error.message : error));
24
+ const rawStdout = textOutput(wrapper.stdout ?? processError.stdout);
25
+ const rawStderr = textOutput(wrapper.stderr ?? processError.stderr);
26
+ const rawCode = processError.exitCode ?? processError.code;
27
+ const exitCode = typeof rawCode === 'number' && Number.isInteger(rawCode) ? rawCode : typeof rawCode === 'string' && rawCode ? rawCode.slice(0, 64) : null;
28
+ const rawSignal = typeof processError.signal === 'string' ? processError.signal : null;
29
+ const signal = rawSignal?.slice(0, 64) ?? null;
30
+ return NativeScenarioFailureEvidenceSchema.parse({
31
+ driver: 'maestro', message: fullMessage.slice(0, FAILURE_MESSAGE_LIMIT), exitCode, signal,
32
+ terminated: processError.killed === true || signal !== null,
33
+ stdout: rawStdout.slice(-FAILURE_OUTPUT_LIMIT), stderr: rawStderr.slice(-FAILURE_OUTPUT_LIMIT),
34
+ outputTruncated: fullMessage.length > FAILURE_MESSAGE_LIMIT || rawStdout.length > FAILURE_OUTPUT_LIMIT || rawStderr.length > FAILURE_OUTPUT_LIMIT || String(rawCode ?? '').length > 64 || String(rawSignal ?? '').length > 64 || rawCode === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
35
+ });
36
+ }
37
+ function safeString(value) {
38
+ if (value.includes('${') || value.includes('\0'))
39
+ throw new BridgeFault('UNSUPPORTED_ACTION', 'Maestro interpolation and NUL characters are not accepted in scenario values');
40
+ return value;
41
+ }
42
+ function validateOptions(options) {
43
+ if (!/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/.test(options.appId))
44
+ throw new BridgeFault('INVALID_INPUT', 'An explicit native application identifier is required');
45
+ if (!options.target.targetId || /[\0\r\n]/.test(options.target.targetId))
46
+ throw new BridgeFault('INVALID_INPUT', 'A native device identifier is required');
47
+ if (options.ssh && !/^[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+$/.test(options.ssh.destination))
48
+ throw new BridgeFault('INVALID_INPUT', 'SSH destination must be user@host');
49
+ }
50
+ function sshArgs(options) { return [...(options.ssh?.identityFile ? ['-i', options.ssh.identityFile] : []), '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', options.ssh.destination]; }
51
+ async function command(options, executable, args) {
52
+ const result = options.ssh ? await execute('ssh', [...sshArgs(options), [executable, ...args].map(quote).join(' ')], { timeout: options.timeoutMs ?? 30000, maxBuffer: 4 * 1024 * 1024 }) : await execute(executable, args, { timeout: options.timeoutMs ?? 30000, maxBuffer: 4 * 1024 * 1024 });
53
+ return result.stdout.trim();
54
+ }
55
+ async function maestro(options, commands) {
56
+ const flow = `appId: ${JSON.stringify(options.appId)}\n---\n${JSON.stringify(commands, null, 2)}\n`;
57
+ const directory = await mkdtemp(path.join(tmpdir(), 'nativemason-maestro-'));
58
+ const file = path.join(directory, 'flow.yaml');
59
+ try {
60
+ await writeFile(file, flow, { mode: 0o600 });
61
+ try {
62
+ if (options.ssh) {
63
+ // SSH stdin carries the generated flow; only validated, quoted command arguments enter its shell.
64
+ const program = `set -e; nm_flow_dir=$(mktemp -d); trap 'rm "$nm_flow_dir/flow.yaml"; rmdir "$nm_flow_dir"' EXIT; cat > "$nm_flow_dir/flow.yaml"; ${[options.maestroPath ?? 'maestro', '--device', options.adbSerial ?? options.target.targetId, 'test'].map(quote).join(' ')} "$nm_flow_dir/flow.yaml"`;
65
+ await new Promise((resolve, reject) => { const child = execFile('ssh', [...sshArgs(options), program], { timeout: options.timeoutMs ?? 120000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => error ? reject({ reason: error, stdout, stderr }) : resolve()); child.stdin?.end(flow); });
66
+ }
67
+ else
68
+ await command({ ...options, timeoutMs: options.timeoutMs ?? 120000 }, options.maestroPath ?? 'maestro', ['--device', options.adbSerial ?? options.target.targetId, 'test', file]);
69
+ }
70
+ catch (error) {
71
+ const evidence = scenarioFailureEvidence(error);
72
+ const reason = evidence.message.slice(0, 256);
73
+ throw new BridgeFault('VERIFICATION_FAILED', reason ? `Native scenario command failed: ${reason}` : 'Native scenario command failed', evidence);
74
+ }
75
+ }
76
+ finally {
77
+ await unlink(file).catch(() => undefined);
78
+ await rmdir(directory).catch(() => undefined);
79
+ }
80
+ }
81
+ /** Native UI actions run against the explicitly selected application and device. */
82
+ export function createNativeScenarioPort(options) {
83
+ validateOptions(options);
84
+ let closed = false;
85
+ return {
86
+ async run(script) {
87
+ if (closed)
88
+ throw new BridgeFault('SESSION_NOT_FOUND', 'Native scenario driver is closed');
89
+ const parsed = ScenarioScriptSchema.parse(script);
90
+ if (options.target.platform === 'ios' && options.target.kind === 'physical' && [...parsed.reset, ...parsed.actions].some(action => action.type !== 'hook'))
91
+ throw new BridgeFault('UNSUPPORTED_ACTION', 'Physical iOS supports registered app hooks; Maestro UI actions require an iOS simulator. Navigate manually before capture.');
92
+ // Reject unsupported static actions before executing earlier hooks or UI actions.
93
+ for (const action of [...parsed.reset, ...parsed.actions]) {
94
+ if (action.type === 'hook' && !options.runHook)
95
+ throw new BridgeFault('UNSUPPORTED_ACTION', 'Native application hooks require an explicitly registered runtime hook transport');
96
+ if (action.type === 'press')
97
+ throw new BridgeFault('UNSUPPORTED_ACTION', 'Held pointer phases are supported in Chromium only; native state requires an app scenario hook');
98
+ if (action.type === 'scroll')
99
+ throw new BridgeFault('UNSUPPORTED_ACTION', 'Native exact scroll offsets are unavailable; use the explicit swipe action');
100
+ if (action.type === 'keyboard' && action.visible)
101
+ throw new BridgeFault('UNSUPPORTED_ACTION', 'Show the native keyboard by tapping an input; visibility must be measured by the application');
102
+ if ('elementId' in action)
103
+ safeString(action.elementId);
104
+ if (action.type === 'navigate')
105
+ safeString(action.url);
106
+ if (action.type === 'input') {
107
+ safeString(action.text);
108
+ if (options.target.platform === 'android' && [...action.text].some(character => character.codePointAt(0) > 127))
109
+ throw new BridgeFault('UNSUPPORTED_ACTION', 'Maestro inputText does not support Unicode on Android');
110
+ }
111
+ if (action.type === 'input' && action.mode === 'replace' && action.text === '')
112
+ throw new BridgeFault('UNSUPPORTED_ACTION', 'Maestro cannot verify an empty native text value when the field may expose a placeholder or accessibility label');
113
+ }
114
+ const commands = [];
115
+ const flush = async () => { if (commands.length) {
116
+ await maestro(options, commands);
117
+ commands.length = 0;
118
+ } };
119
+ for (const action of [...parsed.reset, ...parsed.actions]) {
120
+ if (action.type === 'navigate') {
121
+ commands.push({ openLink: safeString(action.url) });
122
+ continue;
123
+ }
124
+ if (action.type === 'hook') {
125
+ if (!options.runHook)
126
+ throw new BridgeFault('UNSUPPORTED_ACTION', 'Native application hooks require an explicitly registered runtime hook transport');
127
+ await flush();
128
+ await options.runHook(action.name, action.value);
129
+ continue;
130
+ }
131
+ if (action.type === 'keyboard') {
132
+ if (action.visible)
133
+ throw new BridgeFault('UNSUPPORTED_ACTION', 'Show the native keyboard by tapping an input; visibility must be measured by the application');
134
+ commands.push('hideKeyboard');
135
+ continue;
136
+ }
137
+ if (action.type === 'scroll')
138
+ throw new BridgeFault('UNSUPPORTED_ACTION', 'Native exact scroll offsets are unavailable; use the explicit swipe action');
139
+ const selector = { id: regex(safeString(action.elementId)) };
140
+ if (action.type === 'tap')
141
+ commands.push({ tapOn: selector });
142
+ if (action.type === 'swipe')
143
+ commands.push({ swipe: { from: selector, direction: action.direction } });
144
+ if (action.type === 'wait')
145
+ commands.push({ extendedWaitUntil: { visible: selector, timeout: action.timeoutMs } });
146
+ if (action.type === 'input') {
147
+ safeString(action.text);
148
+ commands.push({ tapOn: selector });
149
+ if (action.mode === 'replace')
150
+ commands.push({ copyTextFrom: selector }, { assertTrue: '${maestro.copiedText.length <= 100}' }, { eraseText: 100 });
151
+ commands.push({ inputText: action.text });
152
+ // Maestro may resolve copiedText from hint/accessibility text when the actual text is empty.
153
+ if (action.mode === 'replace')
154
+ commands.push({ copyTextFrom: selector }, { assertTrue: `\${maestro.copiedText === ${JSON.stringify(action.text)}}` });
155
+ }
156
+ }
157
+ await flush();
158
+ },
159
+ async close() { closed = true; },
160
+ };
161
+ }
162
+ const profileOperations = new Set();
163
+ const iosSizes = { '1': 'large', '0.823': 'extra-small', '0.882': 'small', '0.941': 'medium', '1.118': 'extra-large', '1.235': 'extra-extra-large', '1.353': 'extra-extra-extra-large', '1.786': 'accessibility-medium', '2.143': 'accessibility-large', '2.643': 'accessibility-extra-large', '3.143': 'accessibility-extra-extra-large', '3.571': 'accessibility-extra-extra-extra-large' };
164
+ /** Journals device-wide settings before mutation and restores them even when the callback fails. */
165
+ export function createNativeProfilePort(options) {
166
+ validateOptions(options);
167
+ const directory = path.join(homedir(), '.cache', 'nativemason', 'native-settings');
168
+ const journal = path.join(directory, createHash('sha256').update(`${options.ssh?.destination ?? 'local'}:${options.target.platform}:${options.adbSerial ?? options.target.targetId}`).digest('hex') + '.json');
169
+ let active = false;
170
+ const adb = (args) => command(options, 'adb', ['-s', options.adbSerial ?? options.target.targetId, ...args]);
171
+ const settings = (operation, key, value) => adb(['shell', 'settings', operation, 'system', key, ...(value === undefined ? [] : [value])]);
172
+ async function waitForProfile(expected) {
173
+ if (!options.readProfile)
174
+ return;
175
+ const started = Date.now();
176
+ let lastError;
177
+ while (true) {
178
+ try {
179
+ const actual = await options.readProfile();
180
+ lastError = undefined;
181
+ if ((expected.fontScale === undefined || Math.abs(actual.fontScale - expected.fontScale) < 0.01) && (expected.orientation === undefined || actual.orientation === expected.orientation))
182
+ return;
183
+ }
184
+ catch (error) {
185
+ if (!(error instanceof BridgeFault) || !['NATIVE_DISCONNECTED', 'TIMEOUT', 'UNSTABLE_LAYOUT', 'STALE_SOURCE', 'STALE_SNAPSHOT'].includes(error.code))
186
+ throw error;
187
+ lastError = error;
188
+ }
189
+ if (Date.now() - started > (options.timeoutMs ?? 30000))
190
+ throw lastError ?? new BridgeFault('UNSUPPORTED_PROFILE', 'Native runtime did not report the requested font scale and orientation');
191
+ await new Promise(resolve => setTimeout(resolve, 100));
192
+ }
193
+ }
194
+ async function restore(saved) {
195
+ if (saved.platform === 'android') {
196
+ for (const [key, value] of [['font_scale', saved.fontScale], ['user_rotation', saved.rotation], ['accelerometer_rotation', saved.accelerometer]])
197
+ if (value !== undefined) {
198
+ await settings(value === 'null' ? 'delete' : 'put', key, value === 'null' ? undefined : value);
199
+ if (await settings('get', key) !== value)
200
+ throw new BridgeFault('RECOVERY_CONFLICT', `Android did not restore ${key}`);
201
+ }
202
+ }
203
+ else {
204
+ if (saved.contentSize)
205
+ await command(options, 'xcrun', ['simctl', 'ui', options.target.targetId, 'content_size', saved.contentSize]);
206
+ if (saved.orientation)
207
+ await maestro(options, [{ setOrientation: saved.orientation === 'portrait' ? 'PORTRAIT' : 'LANDSCAPE_LEFT' }]);
208
+ }
209
+ await unlink(journal);
210
+ }
211
+ async function recover(owned = false) {
212
+ if (profileOperations.has(journal) && !owned)
213
+ throw new BridgeFault('RECOVERY_CONFLICT', 'Native profile operation is still active');
214
+ let saved;
215
+ try {
216
+ saved = JSON.parse(await readFile(journal, 'utf8'));
217
+ }
218
+ catch (error) {
219
+ if (error.code === 'ENOENT')
220
+ return;
221
+ throw new BridgeFault('RECOVERY_CONFLICT', 'Native settings journal is unreadable');
222
+ }
223
+ if (saved.platform !== options.target.platform || !Number.isInteger(saved.pid) || saved.pid <= 0 || [saved.fontScale, saved.rotation, saved.accelerometer].some(value => value !== undefined && value !== 'null' && !/^\d+(?:\.\d+)?$/.test(value)) || (saved.contentSize !== undefined && !Object.values(iosSizes).includes(saved.contentSize)) || (saved.orientation !== undefined && !['portrait', 'landscape'].includes(saved.orientation)))
224
+ throw new BridgeFault('RECOVERY_CONFLICT', 'Native settings journal is invalid');
225
+ let alive = false;
226
+ try {
227
+ process.kill(saved.pid, 0);
228
+ alive = true;
229
+ }
230
+ catch (error) {
231
+ if (error.code !== 'ESRCH')
232
+ alive = true;
233
+ }
234
+ if (saved.pid === process.pid && (owned || !profileOperations.has(journal)))
235
+ alive = false;
236
+ if (alive)
237
+ throw new BridgeFault('RECOVERY_CONFLICT', 'Another process owns pending native settings; close it before recovery');
238
+ await restore(saved);
239
+ }
240
+ return {
241
+ recover: () => recover(),
242
+ async withProfile(profile, work) {
243
+ if (options.target.platform === 'ios' && options.target.kind === 'physical') {
244
+ if (profile.fontScale !== undefined || profile.orientation !== undefined) {
245
+ if (!options.readProfile)
246
+ throw new BridgeFault('UNSUPPORTED_PROFILE', 'Physical iOS profile checks require runtime measurements');
247
+ const actual = await options.readProfile();
248
+ if ((profile.fontScale !== undefined && Math.abs(actual.fontScale - profile.fontScale) > 0.01) || (profile.orientation !== undefined && actual.orientation !== profile.orientation))
249
+ throw new BridgeFault('UNSUPPORTED_PROFILE', 'Change font scale or orientation manually on the iOS device, then capture its measured profile');
250
+ }
251
+ return work();
252
+ }
253
+ if (active || profileOperations.has(journal))
254
+ throw new BridgeFault('RECOVERY_CONFLICT', 'Native profile operations must be serialized');
255
+ active = true;
256
+ profileOperations.add(journal);
257
+ let saved;
258
+ let journalOwned = false;
259
+ let originalProfile;
260
+ try {
261
+ await recover(true);
262
+ if (options.target.platform === 'ios' && options.target.kind !== 'simulator')
263
+ throw new BridgeFault('UNSUPPORTED_PROFILE', 'iOS profile overrides require a simulator');
264
+ if (options.target.platform === 'ios' && profile.orientation !== undefined && !options.readProfile)
265
+ throw new BridgeFault('UNSUPPORTED_PROFILE', 'iOS orientation changes require measured orientation capability');
266
+ if (options.readProfile)
267
+ originalProfile = await options.readProfile();
268
+ const category = profile.fontScale === undefined ? undefined : iosSizes[String(profile.fontScale)];
269
+ if (options.target.platform === 'ios' && profile.fontScale !== undefined && !category)
270
+ throw new BridgeFault('UNSUPPORTED_PROFILE', 'The requested iOS scale has no supported Dynamic Type category');
271
+ saved = options.target.platform === 'android' ? { pid: process.pid, platform: 'android', fontScale: await settings('get', 'font_scale'), rotation: await settings('get', 'user_rotation'), accelerometer: await settings('get', 'accelerometer_rotation') } : { pid: process.pid, platform: 'ios', contentSize: await command(options, 'xcrun', ['simctl', 'ui', options.target.targetId, 'content_size']), ...(profile.orientation !== undefined ? { orientation: (await options.readProfile()).orientation } : {}) };
272
+ await mkdir(directory, { recursive: true });
273
+ const handle = await open(journal, 'wx', 0o600);
274
+ journalOwned = true;
275
+ try {
276
+ await handle.writeFile(JSON.stringify(saved));
277
+ await handle.sync();
278
+ }
279
+ finally {
280
+ await handle.close();
281
+ }
282
+ if (options.target.platform === 'android') {
283
+ if (profile.fontScale !== undefined) {
284
+ await settings('put', 'font_scale', String(profile.fontScale));
285
+ const actual = Number(await settings('get', 'font_scale'));
286
+ if (Math.abs(actual - profile.fontScale) > 0.001)
287
+ throw new BridgeFault('UNSUPPORTED_PROFILE', 'Android did not accept the requested system font scale');
288
+ }
289
+ if (profile.orientation !== undefined) {
290
+ await settings('put', 'accelerometer_rotation', '0');
291
+ await settings('put', 'user_rotation', profile.orientation === 'landscape' ? '1' : '0');
292
+ }
293
+ }
294
+ else {
295
+ if (category)
296
+ await command(options, 'xcrun', ['simctl', 'ui', options.target.targetId, 'content_size', category]);
297
+ if (profile.orientation !== undefined)
298
+ await maestro(options, [{ setOrientation: profile.orientation === 'portrait' ? 'PORTRAIT' : 'LANDSCAPE_LEFT' }]);
299
+ }
300
+ await waitForProfile(profile);
301
+ return await work();
302
+ }
303
+ finally {
304
+ try {
305
+ if (saved && journalOwned) {
306
+ await restore(saved);
307
+ if (originalProfile)
308
+ await waitForProfile(originalProfile);
309
+ }
310
+ }
311
+ finally {
312
+ active = false;
313
+ profileOperations.delete(journal);
314
+ }
315
+ }
316
+ },
317
+ };
318
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "nativemason",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "nativemason": "./bin/nativemason.js",
7
+ "rn-layout-bridge": "./bin/nativemason.js"
8
+ },
9
+ "exports": {
10
+ ".": "./dist/mcp/index.js",
11
+ "./babel": "./dist/instrumentation/index.js"
12
+ },
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.build.json"
15
+ },
16
+ "dependencies": {
17
+ "@babel/core": "^7.28.5",
18
+ "@babel/types": "^7.28.5",
19
+ "@modelcontextprotocol/sdk": "^1.24.0",
20
+ "@nativemason/contracts": "0.3.0",
21
+ "diff": "^8.0.2",
22
+ "playwright": "^1.56.1",
23
+ "sharp": "^0.35.5",
24
+ "ts-morph": "^27.0.2",
25
+ "ws": "^8.18.3"
26
+ },
27
+ "license": "Apache-2.0",
28
+ "files": [
29
+ "dist",
30
+ "bin",
31
+ "LICENSE",
32
+ "README.md"
33
+ ],
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/gauravsaini/nativemason.git",
37
+ "directory": "packages/bridge"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "provenance": true
42
+ },
43
+ "engines": {
44
+ "node": ">=24"
45
+ },
46
+ "devDependencies": {
47
+ "typescript": "^5.9.3"
48
+ }
6
49
  }