@deeeed/metamask-harness 0.24.0 → 0.25.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.
@@ -0,0 +1,90 @@
1
+ import { evalAsync, navigate, runAdapter } from '../platform/bridge.mjs';
2
+ import { consentParams } from '../../shared/analytics/consent.mjs';
3
+
4
+ const SWITCH_IDS = {
5
+ participate: 'metametrics-switch',
6
+ marketing: 'data-collection-switch',
7
+ };
8
+
9
+ function toggleExpression(testId, expected, timeoutMs) {
10
+ return `(async () => {
11
+ const deadline = Date.now() + ${JSON.stringify(timeoutMs)};
12
+ let invoked = false;
13
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
+ const find = () => {
15
+ const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
16
+ const rootsFor = hook?.getFiberRoots;
17
+ if (!hook?.renderers || typeof rootsFor !== 'function') return null;
18
+ const walk = (fiber) => {
19
+ if (!fiber) return null;
20
+ if (fiber.memoizedProps?.testID === ${JSON.stringify(testId)}) return fiber;
21
+ return walk(fiber.child) || walk(fiber.sibling);
22
+ };
23
+ for (const [id] of hook.renderers) {
24
+ for (const root of rootsFor(id) ?? []) {
25
+ const match = walk(root.current);
26
+ if (match) return match;
27
+ }
28
+ }
29
+ return null;
30
+ };
31
+ while (Date.now() < deadline) {
32
+ const target = find();
33
+ const props = target?.memoizedProps;
34
+ if (props && Boolean(props.value) === ${JSON.stringify(expected)}) {
35
+ return { ok: true, testId: ${JSON.stringify(testId)}, changed: invoked };
36
+ }
37
+ if (props && !invoked) {
38
+ if (typeof props.onValueChange !== 'function') {
39
+ throw new Error('Consent switch has no onValueChange handler: ${testId}');
40
+ }
41
+ invoked = true;
42
+ await props.onValueChange(${JSON.stringify(expected)});
43
+ }
44
+ await delay(100);
45
+ }
46
+ throw new Error('Timed out setting consent switch ${testId} to ${expected}.');
47
+ })()`;
48
+ }
49
+
50
+ function stateExpression(participate, marketing, timeoutMs) {
51
+ return `(async () => {
52
+ const deadline = Date.now() + ${JSON.stringify(timeoutMs)};
53
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
54
+ while (Date.now() < deadline) {
55
+ const state = globalThis.store?.getState?.();
56
+ const analytics = state?.engine?.backgroundState?.AnalyticsController ?? {};
57
+ const consent = {
58
+ optedIn: Boolean(analytics.optedIn),
59
+ dataCollectionForMarketing: Boolean(state?.security?.dataCollectionForMarketing),
60
+ analyticsId: analytics.analyticsId ? 'set' : null
61
+ };
62
+ if (
63
+ consent.optedIn === ${JSON.stringify(participate)} &&
64
+ consent.dataCollectionForMarketing === ${JSON.stringify(marketing)} &&
65
+ (!${JSON.stringify(participate)} || consent.analyticsId === 'set')
66
+ ) return consent;
67
+ await delay(100);
68
+ }
69
+ throw new Error('Timed out reading back Mobile analytics consent.');
70
+ })()`;
71
+ }
72
+
73
+ runAdapter(async (input) => {
74
+ const { participate, marketing, timeoutMs } = consentParams(input.node);
75
+
76
+ const navigation = await navigate(input, 'SecuritySettings');
77
+ await evalAsync(input, toggleExpression(SWITCH_IDS.participate, participate, timeoutMs));
78
+ await evalAsync(input, toggleExpression(SWITCH_IDS.marketing, marketing, timeoutMs));
79
+ const consent = await evalAsync(
80
+ input,
81
+ stateExpression(participate, marketing, timeoutMs),
82
+ );
83
+
84
+ return {
85
+ action: input.action,
86
+ consent,
87
+ navigation,
88
+ proofPath: 'mobile-settings-consent',
89
+ };
90
+ });
@@ -0,0 +1,24 @@
1
+ // Minimal adapter IO for the shared analytics actions.
2
+ //
3
+ // Mirrors the live-adapter contract in src/live-adapter-contract.ts. Neither
4
+ // existing runAdapter fits: extension's lives under actions/extension/platform
5
+ // and core's carries PerpsController teardown and expect_error handling. A
6
+ // `shared/` action importing either would couple every platform to one of them,
7
+ // so this is a deliberate 20-line duplicate of the contract itself.
8
+
9
+ import { readFile, writeFile } from 'node:fs/promises';
10
+
11
+ export async function runAdapter(callback) {
12
+ const inputPath = process.argv[2] || process.env.METAMASK_RECIPE_ADAPTER_INPUT;
13
+ if (!inputPath) throw new Error('Missing live adapter input path.');
14
+ const input = JSON.parse(await readFile(inputPath, 'utf8'));
15
+ try {
16
+ const output = await callback(input);
17
+ await writeFile(input.outputPath, `${JSON.stringify(output, null, 2)}\n`);
18
+ } catch (error) {
19
+ // No output file on failure — that is how the runner distinguishes a failed
20
+ // assertion from a passing one.
21
+ process.exitCode = 1;
22
+ process.stderr.write(`[shared/analytics] ${input.action} failed: ${error?.stack ?? error?.message ?? String(error)}\n`);
23
+ }
24
+ }
@@ -0,0 +1,168 @@
1
+ import { runAdapter } from './_adapter.mjs';
2
+ import { readCollected } from './collector.mjs';
3
+
4
+ const DEFAULT_TIMEOUT_MS = 15000;
5
+ // Why a settle window exists at all: a duplicate emit arrives *after* the
6
+ // expected one. If this returned the instant a count reached its target it
7
+ // would pass on the first of two events and never see the second — which is
8
+ // precisely the defect class this action was built to catch. So: wait until
9
+ // satisfiable, then keep waiting, then judge.
10
+ const DEFAULT_SETTLE_MS = 1500;
11
+
12
+ function nonNegativeNumber(value, name, fallback) {
13
+ if (value === undefined) return fallback;
14
+ if (value === '') throw new Error(`${name} must not be blank.`);
15
+ const parsed = Number(value);
16
+ if (!Number.isFinite(parsed) || parsed < 0) {
17
+ throw new Error(`${name} must be a non-negative number, got ${JSON.stringify(value)}.`);
18
+ }
19
+ return parsed;
20
+ }
21
+
22
+ function nonNegativeInteger(value, name, fallback) {
23
+ const parsed = nonNegativeNumber(value, name, fallback);
24
+ if (!Number.isInteger(parsed)) {
25
+ throw new Error(`${name} must be an integer, got ${JSON.stringify(value)}.`);
26
+ }
27
+ return parsed;
28
+ }
29
+
30
+ function matchesProperties(actual, expected) {
31
+ return Object.entries(expected).every(([key, want]) => {
32
+ const got = actual?.[key];
33
+ if (want !== null && typeof want === 'object') {
34
+ return JSON.stringify(got) === JSON.stringify(want);
35
+ }
36
+ // Segment serialises numbers inconsistently across clients.
37
+ if (typeof want === 'number' && typeof got === 'string' && got.trim() !== '') {
38
+ return Number(got) === want;
39
+ }
40
+ if (typeof got === 'number' && typeof want === 'string' && want.trim() !== '') {
41
+ return got === Number(want);
42
+ }
43
+ return Object.is(got, want);
44
+ });
45
+ }
46
+
47
+ function selectFor(expectation, events) {
48
+ return events.filter(
49
+ (entry) =>
50
+ entry.event === expectation.event &&
51
+ (!expectation.properties || matchesProperties(entry.properties, expectation.properties)),
52
+ );
53
+ }
54
+
55
+ function expectationsFrom(input) {
56
+ const raw = input.node?.expect;
57
+ if (!Array.isArray(raw) || raw.length === 0) {
58
+ throw new Error('metamask.analytics.assert_events requires a non-empty expect array.');
59
+ }
60
+ return raw.map((entry) => {
61
+ if (typeof entry?.event !== 'string' || entry.event.length === 0) {
62
+ throw new Error('Each expect entry requires a non-empty event name.');
63
+ }
64
+ const hasCount = entry.count !== undefined;
65
+ const hasBounds = entry.min !== undefined || entry.max !== undefined;
66
+ if (hasCount && hasBounds) {
67
+ throw new Error(`expect entry for "${entry.event}" sets both count and min/max; pick one.`);
68
+ }
69
+ const expectation = {
70
+ event: entry.event,
71
+ properties: entry.properties ?? null,
72
+ count: hasCount ? nonNegativeInteger(entry.count, `count for "${entry.event}"`) : undefined,
73
+ min: entry.min !== undefined
74
+ ? nonNegativeInteger(entry.min, `min for "${entry.event}"`)
75
+ : hasCount
76
+ ? nonNegativeInteger(entry.count, `count for "${entry.event}"`)
77
+ : 1,
78
+ max: entry.max !== undefined
79
+ ? nonNegativeInteger(entry.max, `max for "${entry.event}"`)
80
+ : hasCount
81
+ ? nonNegativeInteger(entry.count, `count for "${entry.event}"`)
82
+ : undefined,
83
+ };
84
+ if (expectation.max !== undefined && expectation.min > expectation.max) {
85
+ throw new Error(
86
+ `expect entry for "${entry.event}" has min ${expectation.min} greater than max ${expectation.max}.`,
87
+ );
88
+ }
89
+ return expectation;
90
+ });
91
+ }
92
+
93
+ function describe(expectation) {
94
+ const bound =
95
+ expectation.count !== undefined
96
+ ? `exactly ${expectation.count}`
97
+ : `min ${expectation.min}${expectation.max === undefined ? '' : `, max ${expectation.max}`}`;
98
+ const props = expectation.properties ? ` matching ${JSON.stringify(expectation.properties)}` : '';
99
+ return `"${expectation.event}"${props} (${bound})`;
100
+ }
101
+
102
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
103
+
104
+ runAdapter(async (input) => {
105
+ const since = nonNegativeNumber(input.node?.since, 'since', 0);
106
+ const timeoutMs = nonNegativeNumber(input.node?.timeout_ms, 'timeout_ms', DEFAULT_TIMEOUT_MS);
107
+ const settleMs = nonNegativeNumber(input.node?.settle_ms, 'settle_ms', DEFAULT_SETTLE_MS);
108
+ const exact = Boolean(input.node?.exact);
109
+ const expectations = expectationsFrom(input);
110
+
111
+ // Phase 1 — wait until every lower bound is reachable, or time out. Timing out
112
+ // is not itself a failure; the strict judgement below produces the message.
113
+ const deadline = Date.now() + timeoutMs;
114
+ let events = [];
115
+ for (;;) {
116
+ events = await readCollected(input, { since });
117
+ const allMet = expectations.every((e) => selectFor(e, events).length >= e.min);
118
+ if (allMet || Date.now() >= deadline) break;
119
+ await sleep(250);
120
+ }
121
+
122
+ // Phase 2 — settle, so a late duplicate lands before we judge.
123
+ await sleep(settleMs);
124
+ events = await readCollected(input, { since });
125
+
126
+ const failures = [];
127
+ const observed = [];
128
+ for (const expectation of expectations) {
129
+ const matched = selectFor(expectation, events);
130
+ observed.push({ event: expectation.event, matched: matched.length });
131
+ if (expectation.count !== undefined && matched.length !== expectation.count) {
132
+ failures.push(`expected ${describe(expectation)}, got ${matched.length}`);
133
+ continue;
134
+ }
135
+ if (matched.length < expectation.min) {
136
+ failures.push(`expected ${describe(expectation)}, got ${matched.length}`);
137
+ continue;
138
+ }
139
+ if (expectation.max !== undefined && matched.length > expectation.max) {
140
+ failures.push(`expected ${describe(expectation)}, got ${matched.length}`);
141
+ }
142
+ }
143
+
144
+ if (exact) {
145
+ const allowed = new Set(expectations.map((e) => e.event));
146
+ const unexpected = [...new Set(events.map((e) => e.event).filter((name) => name && !allowed.has(name)))];
147
+ if (unexpected.length) {
148
+ failures.push(`exact mode: unexpected event(s) ${unexpected.map((n) => `"${n}"`).join(', ')}`);
149
+ }
150
+ }
151
+
152
+ if (failures.length) {
153
+ const captured = events.map((e) => e.event).filter(Boolean);
154
+ throw new Error(
155
+ [
156
+ `metamask.analytics.assert_events failed:`,
157
+ ...failures.map((line) => ` - ${line}`),
158
+ ` captured since ${since}: ${captured.length ? captured.join(', ') : '(none)'}`,
159
+ // A missing-property failure here is most often a consent problem, not a
160
+ // client bug: platform-adapter strips utm_* unless BOTH
161
+ // participateInMetaMetrics and dataCollectionForMarketing are true.
162
+ ` if utm_* / marketing properties are missing, check MetaMetrics consent before suspecting the client.`,
163
+ ].join('\n'),
164
+ );
165
+ }
166
+
167
+ return { action: input.action, since, exact, observed, capturedCount: events.length };
168
+ });