@deeeed/metamask-harness 0.79.0 → 0.80.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/CHANGELOG.md +47 -0
- package/README.md +1 -1
- package/adapters/extension/ensure-browser.sh +2 -2
- package/adapters/extension/inject.mjs +0 -1
- package/adapters/extension/launch-browser.cjs +39 -613
- package/adapters/extension/readiness.mjs +2 -5
- package/adapters/extension/reattach.sh +2 -2
- package/adapters/extension/stamp-runtime-title.cjs +1 -1
- package/adapters/manifest.json +2 -34
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +57 -1358
- package/adapters/mobile/bridge-runtime/teaching.cjs +20 -0
- package/adapters/mobile/inject.sh +16 -0
- package/adapters/mobile/launch-console-forwarder.cjs +1 -2
- package/adapters/mobile/open-device.sh +2 -2
- package/adapters/mobile/reload-app.mjs +4 -4
- package/adapters/mobile/reset-app-data.sh +1 -1
- package/adapters/mobile/start-console-forwarder.sh +3 -2
- package/adapters/mobile/start-metro.sh +17 -12
- package/adapters/mobile/wait-for-bridge.cjs +2 -2
- package/adapters/shared/harness-source-fingerprint.mjs +13 -8
- package/adapters/shared/open-debug.mjs +2 -2
- package/adapters/shared/sync-wallet-fixture.sh +8 -5
- package/adapters/terminal/cleanup.mjs +2 -1
- package/adapters/terminal/inject.mjs +2 -1
- package/bin/mm-harness +9 -28
- package/dist/adapters/checkout-files.js +16 -0
- package/dist/adapters/console-capture.js +14 -1
- package/dist/adapters/core/actions.js +4 -3
- package/dist/adapters/core/overlay.js +1 -1
- package/dist/adapters/core/readiness.js +28 -1
- package/dist/adapters/core/run.js +2 -2
- package/dist/adapters/core/surface.js +16 -1
- package/dist/adapters/extension/browser-pid.js +56 -0
- package/dist/adapters/extension/console-capture.js +1 -1
- package/dist/adapters/extension/doctor-checks.js +1 -1
- package/dist/adapters/extension/ensure-ready.js +5 -4
- package/dist/adapters/extension/extension-id.js +1 -1
- package/dist/adapters/extension/feature-flags.js +1 -1
- package/dist/adapters/extension/fixtures.js +1 -2
- package/dist/adapters/extension/launch.js +4 -5
- package/dist/adapters/extension/launched-browser.js +1 -2
- package/dist/adapters/extension/network-observer.js +1 -1
- package/dist/adapters/extension/overlay.js +1 -1
- package/dist/adapters/extension/product-config.js +1 -1
- package/dist/adapters/extension/readiness.js +1 -1
- package/dist/adapters/extension/reload.js +1 -1
- package/dist/adapters/extension/run.js +24 -10
- package/dist/adapters/extension/runtime-decision.js +2 -1
- package/dist/adapters/extension/runtime.js +9 -5
- package/dist/adapters/extension/surface.js +15 -2
- package/dist/adapters/extension/verify.js +2 -2
- package/dist/adapters/harness-freshness.js +2 -1
- package/dist/adapters/mobile/android-gfxinfo-performance.js +1 -3
- package/dist/adapters/mobile/app-identity.js +10 -0
- package/dist/adapters/mobile/doctor-checks.js +1 -1
- package/dist/adapters/mobile/fixtures.js +1 -1
- package/dist/adapters/mobile/launch.js +27 -28
- package/dist/adapters/mobile/lifecycle.js +1 -1
- package/dist/adapters/mobile/metro-env.js +9 -59
- package/dist/adapters/mobile/performance-observer.js +1 -1
- package/dist/adapters/mobile/prepare-target.js +1 -1
- package/dist/adapters/mobile/prepare.js +3 -4
- package/dist/adapters/mobile/provision.js +1 -1
- package/dist/adapters/mobile/readiness.js +1 -1
- package/dist/adapters/mobile/recording-target.js +15 -0
- package/dist/adapters/mobile/release-artifact-state.js +1 -1
- package/dist/adapters/mobile/reload.js +1 -1
- package/dist/adapters/mobile/run.js +8 -7
- package/dist/adapters/mobile/runtime-decision.js +2 -4
- package/dist/adapters/mobile/source-freshness.js +22 -126
- package/dist/adapters/mobile/surface.js +26 -3
- package/dist/adapters/mobile/ui.js +16 -5
- package/dist/adapters/mobile/verify.js +2 -3
- package/dist/adapters/performance/cdp-trace.js +1 -1
- package/dist/adapters/performance/device-platform.js +1 -1
- package/dist/adapters/slot-ports.js +1 -1
- package/dist/adapters/surface.js +1 -0
- package/dist/adapters/terminal/console-capture.js +1 -1
- package/dist/adapters/terminal/launch.js +1 -1
- package/dist/adapters/terminal/run.js +25 -2
- package/dist/adapters/terminal/surface.js +7 -1
- package/dist/adapters.js +8 -4
- package/dist/advisor-policy.js +1 -1
- package/dist/checkout-view.js +1 -2
- package/dist/cli.js +28 -15
- package/dist/command-contract.js +1 -26
- package/dist/commands/call.js +10 -24
- package/dist/commands/check.js +3 -4
- package/dist/commands/checklist.js +1 -1
- package/dist/commands/completion-candidates.js +2 -10
- package/dist/commands/config.js +2 -2
- package/dist/commands/core-readiness.js +12 -7
- package/dist/commands/debug.js +3 -3
- package/dist/commands/device-target.js +5 -5
- package/dist/commands/doctor.js +2 -14
- package/dist/commands/domain.js +2 -2
- package/dist/commands/ensure-ready.js +1 -7
- package/dist/commands/fixtures.js +5 -6
- package/dist/commands/help.js +3 -4
- package/dist/commands/launch/device.js +10 -17
- package/dist/commands/launch/extension.js +2 -8
- package/dist/commands/launch/mobile.js +1 -1
- package/dist/commands/list-executables.js +1 -3
- package/dist/commands/logs.js +3 -3
- package/dist/commands/manifest.js +1 -12
- package/dist/commands/mobile-device-view.js +4 -2
- package/dist/commands/overlay.js +110 -0
- package/dist/commands/pin.js +1 -1
- package/dist/commands/pr-body.js +2 -2
- package/dist/commands/prepare.js +4 -9
- package/dist/commands/provision.js +3 -12
- package/dist/commands/recipe-advice.js +1 -2
- package/dist/commands/recipe-quality.js +2 -3
- package/dist/commands/resolve-extension.js +1 -7
- package/dist/commands/review.js +4 -4
- package/dist/commands/run-engine.js +12 -51
- package/dist/commands/run.js +4 -25
- package/dist/commands/runtime-decision.js +1 -6
- package/dist/commands/runtime-health.js +1 -7
- package/dist/commands/runtime-launch.js +1 -13
- package/dist/commands/self-test.js +2 -6
- package/dist/commands/status-watch.js +1 -2
- package/dist/commands/status.js +2 -5
- package/dist/commands/task-init.js +2 -3
- package/dist/commands/tutorial.js +2 -2
- package/dist/commands/update.js +1 -1
- package/dist/completions-cache.js +1 -1
- package/dist/console-classifier.js +19 -0
- package/dist/doctor.js +2 -3
- package/dist/funding-execution-context.js +15 -1
- package/dist/host.js +32 -0
- package/dist/index.js +1 -6
- package/dist/live-tsx.js +49 -0
- package/dist/mm-harness-cli.js +2 -4
- package/dist/network-observation.js +4 -4
- package/dist/paths.js +34 -64
- package/dist/performance-observation.js +2 -2
- package/dist/progress.js +1 -1
- package/dist/recipe-assistance-context.js +1 -3
- package/dist/recipe-security.js +2 -59
- package/dist/review/knowledge.js +2 -2
- package/dist/runner.js +5 -3
- package/dist/runtime-context.js +1 -1
- package/dist/task-view.js +2 -3
- package/docs/CONTRIBUTING.md +1 -1
- package/docs/cli-contract.md +2 -2
- package/docs/feature-guard.md +90 -0
- package/library/README.md +3 -1
- package/library/actions/core/perps/_controller.mjs +153 -130
- package/library/actions/core/perps/quote_fees.mjs +58 -0
- package/library/actions/core/wallet/_fixture-accounts.mjs +69 -0
- package/library/actions/core/wallet/list_accounts.mjs +14 -57
- package/library/actions/extension/feature_flags/state.mjs +1 -1
- package/library/actions/extension/platform/cdp.mjs +2 -8
- package/library/actions/harness-exports.mjs +1 -6
- package/library/actions/mobile/app/network-control.mjs +6 -8
- package/library/actions/mobile/perps/prepare_local_snapshot_endpoint.mjs +1 -1
- package/library/actions/mobile/platform/bridge.mjs +8 -8
- package/library/actions/mobile/platform/native-session-name.mjs +21 -1
- package/library/actions/mobile/platform/native-session.mjs +3 -4
- package/library/actions/mobile/platform/observe-ui.mjs +12 -6
- package/library/actions/mobile/ui/native-navigation.mjs +4 -3
- package/library/actions/mobile/wallet/native-ui.mjs +3 -2
- package/library/actions/shared/analytics/_adapter.mjs +1 -1
- package/library/actions/shared/console-findings.mjs +1 -1
- package/library/actions/terminal/platform/runtime.mjs +2 -1
- package/library/manifests/core.action-manifest.json +83 -13
- package/package.json +8 -9
- package/adapters/extension/lib/slot-title.cjs +0 -292
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +0 -472
- package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +0 -125
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +0 -1055
- package/adapters/mobile/bridge-runtime/lib/cdp-eval.cjs +0 -110
- package/adapters/mobile/bridge-runtime/lib/config.cjs +0 -61
- package/adapters/mobile/bridge-runtime/lib/console-format.cjs +0 -53
- package/adapters/mobile/bridge-runtime/lib/devtools-proxy.cjs +0 -177
- package/adapters/mobile/bridge-runtime/lib/issue-capture.cjs +0 -446
- package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +0 -84
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +0 -403
- package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +0 -116
- package/adapters/mobile/coalesce-metro-log.cjs +0 -24
- package/adapters/mobile/launch-metro.cjs +0 -77
- package/adapters/mobile/metro-config.cjs +0 -98
- package/adapters/mobile/metro-log-generation.cjs +0 -106
- package/dist/adapters/mobile/frame-metrics.js +0 -118
- package/dist/adapters/mobile/video-recorder.js +0 -289
- package/dist/artifact-files.js +0 -92
- package/dist/checkout-lock.js +0 -149
- package/dist/cli-color.js +0 -98
- package/dist/command-journal.js +0 -308
- package/dist/commands/last.js +0 -60
- package/dist/commands/launch/index.js +0 -109
- package/dist/commands/parse-args.js +0 -279
- package/dist/commands/reload.js +0 -41
- package/dist/commands/run-report.js +0 -241
- package/dist/commands/shared.js +0 -323
- package/dist/commands/stop.js +0 -60
- package/dist/devices.js +0 -100
- package/dist/execution-provenance.js +0 -332
- package/dist/harness.js +0 -451
- package/dist/heal-bounds.js +0 -137
- package/dist/json-stream.js +0 -60
- package/dist/leaf-invoke.js +0 -19
- package/dist/library-provenance.js +0 -33
- package/dist/live-adapter-contract.js +0 -380
- package/dist/recording-target.js +0 -120
- package/dist/run-diagnostics.js +0 -364
- package/dist/run-recording.js +0 -481
- package/dist/runtime-proof.js +0 -90
- package/library/actions/mobile/platform/tool-paths.mjs +0 -123
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('node:fs');
|
|
4
|
-
const { createRequire } = require('node:module');
|
|
5
|
-
const path = require('node:path');
|
|
6
|
-
const { pathToFileURL } = require('node:url');
|
|
7
|
-
|
|
8
|
-
function escapeRegExp(value) {
|
|
9
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function appendBlockList(blockList, pattern) {
|
|
13
|
-
if (Array.isArray(blockList)) return [...blockList, pattern];
|
|
14
|
-
return blockList instanceof RegExp ? [blockList, pattern] : [pattern];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function blockListFlags(blockList) {
|
|
18
|
-
const first = Array.isArray(blockList) ? blockList[0] : blockList;
|
|
19
|
-
return first instanceof RegExp && first.ignoreCase ? 'i' : '';
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function resolveExpoConfigLoader(projectRoot) {
|
|
23
|
-
const requireFromProject = createRequire(path.join(projectRoot, 'package.json'));
|
|
24
|
-
let requireUtilsPath;
|
|
25
|
-
try {
|
|
26
|
-
requireUtilsPath = requireFromProject.resolve('@expo/require-utils');
|
|
27
|
-
} catch (error) {
|
|
28
|
-
if (error?.code !== 'MODULE_NOT_FOUND') throw error;
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
const { loadModuleSync } = requireFromProject(requireUtilsPath);
|
|
32
|
-
return typeof loadModuleSync === 'function' ? loadModuleSync : null;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function loadProductConfig(configPath, baseConfig, projectRoot) {
|
|
36
|
-
let loaded;
|
|
37
|
-
const expoConfigLoader = resolveExpoConfigLoader(projectRoot);
|
|
38
|
-
if (expoConfigLoader) {
|
|
39
|
-
loaded = expoConfigLoader(configPath);
|
|
40
|
-
} else {
|
|
41
|
-
try {
|
|
42
|
-
loaded = require(configPath);
|
|
43
|
-
} catch (error) {
|
|
44
|
-
if (error?.code !== 'ERR_REQUIRE_ESM') throw error;
|
|
45
|
-
loaded = await import(pathToFileURL(configPath).href);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const moduleValue = await loaded;
|
|
50
|
-
const exported =
|
|
51
|
-
moduleValue?.__esModule || moduleValue?.[Symbol.toStringTag] === 'Module'
|
|
52
|
-
? moduleValue.default
|
|
53
|
-
: moduleValue;
|
|
54
|
-
const config = await exported;
|
|
55
|
-
return typeof config === 'function' ? config(baseConfig) : config;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
module.exports = async function harnessMetroConfig(baseConfig) {
|
|
59
|
-
const projectRoot = path.resolve(
|
|
60
|
-
process.env.MM_HARNESS_METRO_PROJECT_ROOT || process.cwd(),
|
|
61
|
-
);
|
|
62
|
-
const productConfigPath = path.resolve(
|
|
63
|
-
process.env.MM_HARNESS_METRO_BASE_CONFIG ||
|
|
64
|
-
path.join(projectRoot, 'metro.config.js'),
|
|
65
|
-
);
|
|
66
|
-
if (productConfigPath === __filename) {
|
|
67
|
-
throw new Error(
|
|
68
|
-
'MM_HARNESS_METRO_BASE_CONFIG must reference the product Metro config, not the harness wrapper.',
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
let productConfig = baseConfig;
|
|
72
|
-
if (fs.existsSync(productConfigPath)) {
|
|
73
|
-
productConfig = await loadProductConfig(
|
|
74
|
-
productConfigPath,
|
|
75
|
-
baseConfig,
|
|
76
|
-
projectRoot,
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const tempRoot = path.join(projectRoot, 'temp');
|
|
81
|
-
const existingBlockList =
|
|
82
|
-
productConfig.resolver?.blockList ?? baseConfig.resolver?.blockList;
|
|
83
|
-
const tempPattern = new RegExp(
|
|
84
|
-
`^${escapeRegExp(tempRoot)}(?:${escapeRegExp(path.sep)}|$)`,
|
|
85
|
-
blockListFlags(existingBlockList),
|
|
86
|
-
);
|
|
87
|
-
// Inline environment values affect transformed JavaScript. Keep separate
|
|
88
|
-
// cache generations for those inputs instead of deleting the shared cache.
|
|
89
|
-
const envFingerprint = process.env.MM_HARNESS_METRO_ENV_FINGERPRINT;
|
|
90
|
-
return {
|
|
91
|
-
...productConfig,
|
|
92
|
-
...(envFingerprint ? { cacheVersion: `${productConfig.cacheVersion ?? ''}:mm-env:${envFingerprint}` } : {}),
|
|
93
|
-
resolver: {
|
|
94
|
-
...productConfig.resolver,
|
|
95
|
-
blockList: appendBlockList(existingBlockList, tempPattern),
|
|
96
|
-
},
|
|
97
|
-
};
|
|
98
|
-
};
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
const crypto = require('node:crypto');
|
|
5
|
-
const fs = require('node:fs');
|
|
6
|
-
const path = require('node:path');
|
|
7
|
-
|
|
8
|
-
const generationIdPattern = /^[A-Za-z0-9-]+$/u;
|
|
9
|
-
|
|
10
|
-
function archiveGenerationId(name) {
|
|
11
|
-
if (!name.startsWith('metro.') || !name.endsWith('.log')) return null;
|
|
12
|
-
const generationId = name.slice('metro.'.length, -'.log'.length);
|
|
13
|
-
return generationIdPattern.test(generationId) ? generationId : null;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function positiveInteger(name, fallback) {
|
|
17
|
-
const raw = process.env[name];
|
|
18
|
-
if (raw === undefined || raw === '') return fallback;
|
|
19
|
-
if (!/^[1-9][0-9]*$/u.test(raw)) throw new Error(`${name} must be a positive integer`);
|
|
20
|
-
return Number(raw);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function main() {
|
|
24
|
-
const runtimeArg = process.argv[2];
|
|
25
|
-
const port = process.argv[3];
|
|
26
|
-
const reason = process.argv[4];
|
|
27
|
-
if (!runtimeArg || !/^[1-9][0-9]*$/u.test(port ?? '') || !reason) {
|
|
28
|
-
throw new Error('usage: metro-log-generation.cjs <runtime-dir> <port> <reason>');
|
|
29
|
-
}
|
|
30
|
-
const runtimeDir = fs.realpathSync(runtimeArg);
|
|
31
|
-
const activeLog = path.join(runtimeDir, 'metro.log');
|
|
32
|
-
const evidenceFile = path.join(runtimeDir, 'metro-generation.json');
|
|
33
|
-
const maxArchives = positiveInteger('MM_HARNESS_METRO_LOG_ARCHIVE_COUNT', 4);
|
|
34
|
-
const maxArchiveBytes = positiveInteger('MM_HARNESS_METRO_LOG_ARCHIVE_BYTES', 8 * 1024 * 1024);
|
|
35
|
-
const generationId = `${new Date().toISOString().replace(/[-:.]/gu, '')}-${port}-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
36
|
-
if (!generationIdPattern.test(generationId)) throw new Error('generated Metro log identity is invalid');
|
|
37
|
-
let rotatedLog = null;
|
|
38
|
-
|
|
39
|
-
rotatedLog = path.join(runtimeDir, `metro.${generationId}.log`);
|
|
40
|
-
try {
|
|
41
|
-
fs.renameSync(activeLog, rotatedLog);
|
|
42
|
-
const stat = fs.lstatSync(rotatedLog);
|
|
43
|
-
if (!stat.isFile()) {
|
|
44
|
-
fs.renameSync(rotatedLog, activeLog);
|
|
45
|
-
throw new Error('metro.log must be a regular file');
|
|
46
|
-
}
|
|
47
|
-
} catch (error) {
|
|
48
|
-
if (error && error.code === 'ENOENT') rotatedLog = null;
|
|
49
|
-
else throw error;
|
|
50
|
-
}
|
|
51
|
-
try {
|
|
52
|
-
fs.closeSync(fs.openSync(activeLog, 'wx', 0o600));
|
|
53
|
-
} catch (error) {
|
|
54
|
-
if (error && error.code === 'EEXIST') throw new Error('another Metro generation recreated metro.log; refusing to truncate it');
|
|
55
|
-
throw error;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const archives = fs.readdirSync(runtimeDir)
|
|
59
|
-
.filter((name) => archiveGenerationId(name) !== null)
|
|
60
|
-
.map((name) => {
|
|
61
|
-
const file = path.join(runtimeDir, name);
|
|
62
|
-
const stat = fs.lstatSync(file);
|
|
63
|
-
if (!stat.isFile()) return null;
|
|
64
|
-
return { file, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
65
|
-
})
|
|
66
|
-
.filter(Boolean)
|
|
67
|
-
.sort((left, right) => right.mtimeMs - left.mtimeMs || right.file.localeCompare(left.file));
|
|
68
|
-
|
|
69
|
-
let retainedBytes = 0;
|
|
70
|
-
let retainedCount = 0;
|
|
71
|
-
const retained = [];
|
|
72
|
-
const removed = [];
|
|
73
|
-
for (const archive of archives) {
|
|
74
|
-
if (retainedCount < maxArchives && retainedBytes + archive.size <= maxArchiveBytes) {
|
|
75
|
-
retained.push(archive.file);
|
|
76
|
-
retainedCount += 1;
|
|
77
|
-
retainedBytes += archive.size;
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
fs.unlinkSync(archive.file);
|
|
81
|
-
removed.push({ path: archive.file, size: archive.size, reason: retainedCount >= maxArchives ? 'count-limit' : 'size-limit' });
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const evidence = {
|
|
85
|
-
schemaVersion: 1,
|
|
86
|
-
generationId,
|
|
87
|
-
port: Number(port),
|
|
88
|
-
startedAt: new Date().toISOString(),
|
|
89
|
-
currentLog: activeLog,
|
|
90
|
-
rotatedLog: rotatedLog && fs.existsSync(rotatedLog) ? rotatedLog : null,
|
|
91
|
-
archivedLogs: retained,
|
|
92
|
-
rotationReason: reason,
|
|
93
|
-
retention: { maxArchives, maxArchiveBytes, retainedBytes, removed },
|
|
94
|
-
};
|
|
95
|
-
const temporary = `${evidenceFile}.${process.pid}.tmp`;
|
|
96
|
-
fs.writeFileSync(temporary, `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
97
|
-
fs.renameSync(temporary, evidenceFile);
|
|
98
|
-
process.stdout.write(`${JSON.stringify(evidence)}\n`);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
main();
|
|
103
|
-
} catch (error) {
|
|
104
|
-
console.error(`metro-log-generation: ${error instanceof Error ? error.message : String(error)}`);
|
|
105
|
-
process.exit(1);
|
|
106
|
-
}
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
const MIN_ACTIVE_INTERVALS = 5;
|
|
2
|
-
function summarizeFrames(samples) {
|
|
3
|
-
const allValidSamples = samples.filter(
|
|
4
|
-
(sample) => Number.isFinite(sample.durationMs) && sample.durationMs > 0
|
|
5
|
-
);
|
|
6
|
-
const frameBudgetMs = commonFrameBudget(allValidSamples);
|
|
7
|
-
const cadenceSamples = allValidSamples.length > 0 && allValidSamples.every((sample) => sample.cadenceInterval === true);
|
|
8
|
-
const cadenceGaps = cadenceSamples && frameBudgetMs ? allValidSamples.filter(
|
|
9
|
-
(sample) => sample.durationMs > frameBudgetMs * 3.01
|
|
10
|
-
) : [];
|
|
11
|
-
const validSamples = cadenceGaps.length > 0 ? allValidSamples.filter(
|
|
12
|
-
(sample) => sample.durationMs <= (frameBudgetMs ?? 0) * 3.01
|
|
13
|
-
) : allValidSamples;
|
|
14
|
-
const durations = validSamples.map((sample) => sample.durationMs).sort((first, second) => first - second);
|
|
15
|
-
if (durations.length === 0) {
|
|
16
|
-
return {
|
|
17
|
-
fpsUnavailableReason: frameBudgetMs ? "insufficient_active_frames" : "refresh_rate_unavailable",
|
|
18
|
-
...cadenceGaps.length > 0 ? {
|
|
19
|
-
cadenceGapCount: cadenceGaps.length,
|
|
20
|
-
longestCadenceGapMs: round(
|
|
21
|
-
Math.max(...cadenceGaps.map((sample) => sample.durationMs))
|
|
22
|
-
)
|
|
23
|
-
} : {},
|
|
24
|
-
...frameBudgetMs ? {
|
|
25
|
-
frameBudgetMs: round(frameBudgetMs),
|
|
26
|
-
refreshRateHz: round(1e3 / frameBudgetMs)
|
|
27
|
-
} : {},
|
|
28
|
-
frameCount: 0
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
const frameBudgetOutcomes = validSamples.map((sample) => sample.overBudget).filter((value) => value !== void 0);
|
|
32
|
-
const missedFrameBudgetCount = frameBudgetOutcomes.filter(Boolean).length;
|
|
33
|
-
const activeIntervals = frameBudgetMs ? activeRenderingIntervals(validSamples, frameBudgetMs) : [];
|
|
34
|
-
const activeElapsedMs = activeIntervals.reduce(
|
|
35
|
-
(total, interval) => total + interval,
|
|
36
|
-
0
|
|
37
|
-
);
|
|
38
|
-
const hasActiveFps = cadenceGaps.length === 0 && activeIntervals.length >= MIN_ACTIVE_INTERVALS && activeElapsedMs > 0;
|
|
39
|
-
const refreshRateHz = frameBudgetMs ? 1e3 / frameBudgetMs : void 0;
|
|
40
|
-
return {
|
|
41
|
-
...hasActiveFps ? {
|
|
42
|
-
activeFps: round(
|
|
43
|
-
Math.min(
|
|
44
|
-
refreshRateHz ?? Number.POSITIVE_INFINITY,
|
|
45
|
-
activeIntervals.length * 1e3 / activeElapsedMs
|
|
46
|
-
)
|
|
47
|
-
),
|
|
48
|
-
fpsIntervalCount: activeIntervals.length
|
|
49
|
-
} : {
|
|
50
|
-
fpsUnavailableReason: frameBudgetMs ? cadenceGaps.length > 0 ? "unclassified_cadence_gap" : "insufficient_active_frames" : "refresh_rate_unavailable"
|
|
51
|
-
},
|
|
52
|
-
...cadenceGaps.length > 0 ? {
|
|
53
|
-
cadenceGapCount: cadenceGaps.length,
|
|
54
|
-
longestCadenceGapMs: round(
|
|
55
|
-
Math.max(...cadenceGaps.map((sample) => sample.durationMs))
|
|
56
|
-
)
|
|
57
|
-
} : {},
|
|
58
|
-
...frameBudgetMs ? {
|
|
59
|
-
frameBudgetMs: round(frameBudgetMs),
|
|
60
|
-
refreshRateHz: round(1e3 / frameBudgetMs)
|
|
61
|
-
} : {},
|
|
62
|
-
frameCount: durations.length,
|
|
63
|
-
...frameBudgetOutcomes.length === validSamples.length ? {
|
|
64
|
-
overBudgetFrameCount: missedFrameBudgetCount,
|
|
65
|
-
overBudgetFramePercent: round(
|
|
66
|
-
missedFrameBudgetCount / validSamples.length * 100
|
|
67
|
-
)
|
|
68
|
-
} : {},
|
|
69
|
-
longestFrameMs: round(durations.at(-1) ?? 0),
|
|
70
|
-
p50FrameMs: round(percentile(durations, 0.5)),
|
|
71
|
-
p95FrameMs: round(percentile(durations, 0.95)),
|
|
72
|
-
p99FrameMs: round(percentile(durations, 0.99))
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
function commonFrameBudget(samples) {
|
|
76
|
-
const budgets = samples.map((sample) => sample.frameBudgetMs).filter(
|
|
77
|
-
(value) => value !== void 0 && Number.isFinite(value) && value > 0
|
|
78
|
-
).sort((first, second) => first - second);
|
|
79
|
-
return budgets.length > 0 ? percentile(budgets, 0.5) : void 0;
|
|
80
|
-
}
|
|
81
|
-
function activeRenderingIntervals(samples, frameBudgetMs) {
|
|
82
|
-
if (samples.every((sample) => sample.cadenceInterval === true)) {
|
|
83
|
-
return samples.map((sample) => sample.durationMs).filter(
|
|
84
|
-
(durationMs) => Number.isFinite(durationMs) && durationMs > 0 && durationMs <= frameBudgetMs * 3.01
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
const ordered = [...samples].map((sample) => ({
|
|
88
|
-
...sample,
|
|
89
|
-
startedAtEpochMs: sample.startedAtEpochMs ?? sample.completedAtEpochMs - sample.durationMs
|
|
90
|
-
})).sort((first, second) => first.startedAtEpochMs - second.startedAtEpochMs);
|
|
91
|
-
const intervals = [];
|
|
92
|
-
for (let index = 1; index < ordered.length; index += 1) {
|
|
93
|
-
const previous = ordered[index - 1];
|
|
94
|
-
const current = ordered[index];
|
|
95
|
-
if (!previous || !current) continue;
|
|
96
|
-
const interval = current.startedAtEpochMs - previous.startedAtEpochMs;
|
|
97
|
-
if (!Number.isFinite(interval) || interval <= 0) continue;
|
|
98
|
-
const continuous = interval <= frameBudgetMs * 3.01;
|
|
99
|
-
const explainedBySlowFrame = Math.max(previous.durationMs, current.durationMs) >= interval - frameBudgetMs * 1.01;
|
|
100
|
-
if (continuous || explainedBySlowFrame) intervals.push(interval);
|
|
101
|
-
}
|
|
102
|
-
return intervals;
|
|
103
|
-
}
|
|
104
|
-
function percentile(sorted, quantile) {
|
|
105
|
-
if (sorted.length === 1) return sorted[0] ?? 0;
|
|
106
|
-
const position = (sorted.length - 1) * quantile;
|
|
107
|
-
const lower = Math.floor(position);
|
|
108
|
-
const upper = Math.ceil(position);
|
|
109
|
-
const lowerValue = sorted[lower] ?? 0;
|
|
110
|
-
const upperValue = sorted[upper] ?? lowerValue;
|
|
111
|
-
return lowerValue + (upperValue - lowerValue) * (position - lower);
|
|
112
|
-
}
|
|
113
|
-
function round(value) {
|
|
114
|
-
return Math.round(value * 1e3) / 1e3;
|
|
115
|
-
}
|
|
116
|
-
export {
|
|
117
|
-
summarizeFrames
|
|
118
|
-
};
|
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { optionalVideoTiming } from "@farmslot/recipe-runner";
|
|
5
|
-
import {
|
|
6
|
-
ANDROID_RECORDING_TARGET_PREFIX,
|
|
7
|
-
IOS_SIMULATOR_RECORDING_TARGET_PREFIX
|
|
8
|
-
} from "../../recording-target.js";
|
|
9
|
-
const RECORDING_START_TIMEOUT_MS = 5e3;
|
|
10
|
-
const RECORDING_STOP_TIMEOUT_MS = 1e4;
|
|
11
|
-
const RECORDING_TIME_LIMIT_SECONDS = 0;
|
|
12
|
-
function androidScreenrecordArgs(remotePath) {
|
|
13
|
-
return [
|
|
14
|
-
"shell",
|
|
15
|
-
"screenrecord",
|
|
16
|
-
"--time-limit",
|
|
17
|
-
String(RECORDING_TIME_LIMIT_SECONDS),
|
|
18
|
-
remotePath
|
|
19
|
-
];
|
|
20
|
-
}
|
|
21
|
-
function createAndroidVideoRecorder(serial) {
|
|
22
|
-
return {
|
|
23
|
-
name: "adb-screenrecord",
|
|
24
|
-
platform: "android",
|
|
25
|
-
doctor: async () => doctorAndroidRecorder(serial),
|
|
26
|
-
start: (request) => startAndroidRecording(serial, request)
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
function createIosSimulatorVideoRecorder(simulator) {
|
|
30
|
-
return {
|
|
31
|
-
name: "simctl-recordVideo",
|
|
32
|
-
platform: "ios",
|
|
33
|
-
doctor: async () => doctorIosSimulatorRecorder(simulator),
|
|
34
|
-
start: (request) => startIosSimulatorRecording(simulator, request)
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
function doctorIosSimulatorRecorder(simulator) {
|
|
38
|
-
const state = runSimctl(["getenv", simulator, "SIMULATOR_UDID"]);
|
|
39
|
-
if (state.status !== 0 || !state.stdout.trim()) {
|
|
40
|
-
return {
|
|
41
|
-
ok: false,
|
|
42
|
-
code: "ios_simulator_unavailable",
|
|
43
|
-
message: `iOS simulator ${simulator} is not booted: ${commandFailure(state)}`,
|
|
44
|
-
suggestedFix: `Boot ${simulator} and run xcrun simctl getenv ${simulator} SIMULATOR_UDID.`
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
return {
|
|
48
|
-
ok: true,
|
|
49
|
-
code: "ok",
|
|
50
|
-
message: `iOS simulator recording is ready on ${simulator}.`
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
async function startIosSimulatorRecording(simulator, request) {
|
|
54
|
-
const targetSimulator = iosTargetSimulator(request);
|
|
55
|
-
if (targetSimulator !== simulator) {
|
|
56
|
-
throw new Error(
|
|
57
|
-
`iOS recording target ${targetSimulator ?? "<missing>"} does not match selected simulator ${simulator}.`
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
fs.mkdirSync(path.dirname(request.outputPath), { recursive: true });
|
|
61
|
-
const child = spawn(
|
|
62
|
-
"xcrun",
|
|
63
|
-
[
|
|
64
|
-
"simctl",
|
|
65
|
-
"io",
|
|
66
|
-
simulator,
|
|
67
|
-
"recordVideo",
|
|
68
|
-
"--codec=h264",
|
|
69
|
-
"--force",
|
|
70
|
-
request.outputPath
|
|
71
|
-
],
|
|
72
|
-
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
73
|
-
);
|
|
74
|
-
const output = [];
|
|
75
|
-
child.stdout?.setEncoding("utf8");
|
|
76
|
-
child.stderr?.setEncoding("utf8");
|
|
77
|
-
child.stdout?.on("data", (chunk) => output.push(String(chunk)));
|
|
78
|
-
child.stderr?.on("data", (chunk) => output.push(String(chunk)));
|
|
79
|
-
await waitForIosRecordingStart(child, output);
|
|
80
|
-
let stopped = false;
|
|
81
|
-
return {
|
|
82
|
-
async stop() {
|
|
83
|
-
if (stopped) {
|
|
84
|
-
throw new Error(
|
|
85
|
-
`iOS simulator recording on ${simulator} was already stopped.`
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
stopped = true;
|
|
89
|
-
child.kill("SIGINT");
|
|
90
|
-
await waitForChildExit(
|
|
91
|
-
child,
|
|
92
|
-
RECORDING_STOP_TIMEOUT_MS,
|
|
93
|
-
"iOS simulator recordVideo"
|
|
94
|
-
);
|
|
95
|
-
const recorded = fs.statSync(request.outputPath);
|
|
96
|
-
if (!recorded.isFile() || recorded.size <= 0) {
|
|
97
|
-
throw new Error(
|
|
98
|
-
`iOS simulator recording output is empty: ${request.outputPath}`
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
return {
|
|
102
|
-
recorder: {
|
|
103
|
-
name: "simctl-recordVideo",
|
|
104
|
-
platform: "ios",
|
|
105
|
-
target: { selector: "simulator", value: simulator }
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
function doctorAndroidRecorder(serial) {
|
|
112
|
-
const state = runAdb(serial, ["get-state"]);
|
|
113
|
-
if (state.status !== 0 || state.stdout.trim() !== "device") {
|
|
114
|
-
return {
|
|
115
|
-
ok: false,
|
|
116
|
-
code: "android_device_unavailable",
|
|
117
|
-
message: `Android device ${serial} is not ready: ${commandFailure(state)}`,
|
|
118
|
-
suggestedFix: `Run adb -s ${serial} get-state and reconnect or unlock the device.`
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
const screenrecord = runAdb(serial, ["shell", "screenrecord", "--help"]);
|
|
122
|
-
if (screenrecord.status !== 0) {
|
|
123
|
-
return {
|
|
124
|
-
ok: false,
|
|
125
|
-
code: "android_screenrecord_unavailable",
|
|
126
|
-
message: `Android screenrecord is unavailable on ${serial}: ${commandFailure(screenrecord)}`,
|
|
127
|
-
suggestedFix: `Run adb -s ${serial} shell screenrecord --help.`
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
return {
|
|
131
|
-
ok: true,
|
|
132
|
-
code: "ok",
|
|
133
|
-
message: `Android screen recording is ready on ${serial}.`
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
async function startAndroidRecording(serial, request) {
|
|
137
|
-
const targetSerial = androidTargetSerial(request);
|
|
138
|
-
if (targetSerial !== serial) {
|
|
139
|
-
throw new Error(
|
|
140
|
-
`Android recording target ${targetSerial ?? "<missing>"} does not match selected device ${serial}.`
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
const existingPid = screenrecordPid(serial);
|
|
144
|
-
if (existingPid) {
|
|
145
|
-
throw new Error(
|
|
146
|
-
`Android screenrecord is already running on ${serial} (pid ${existingPid}). Stop that recording before retrying.`
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
const remotePath = `/sdcard/Download/mm-harness-recipe-${Date.now()}-${process.pid}.mp4`;
|
|
150
|
-
const startedAtUnixMs = Date.now();
|
|
151
|
-
const child = spawn(
|
|
152
|
-
"adb",
|
|
153
|
-
["-s", serial, ...androidScreenrecordArgs(remotePath)],
|
|
154
|
-
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
155
|
-
);
|
|
156
|
-
const stderr = [];
|
|
157
|
-
child.stderr?.setEncoding("utf8");
|
|
158
|
-
child.stderr?.on("data", (chunk) => stderr.push(String(chunk)));
|
|
159
|
-
child.stdout?.resume();
|
|
160
|
-
const recorderPid = await waitForScreenrecordStart(serial, child, stderr);
|
|
161
|
-
let stopped = false;
|
|
162
|
-
return {
|
|
163
|
-
async stop() {
|
|
164
|
-
if (stopped) {
|
|
165
|
-
throw new Error(`Android screen recording on ${serial} was already stopped.`);
|
|
166
|
-
}
|
|
167
|
-
stopped = true;
|
|
168
|
-
try {
|
|
169
|
-
stopRemoteScreenrecord(serial, recorderPid);
|
|
170
|
-
await waitForChildExit(
|
|
171
|
-
child,
|
|
172
|
-
RECORDING_STOP_TIMEOUT_MS,
|
|
173
|
-
"Android screenrecord"
|
|
174
|
-
);
|
|
175
|
-
const stoppedAtUnixMs = Date.now();
|
|
176
|
-
fs.mkdirSync(path.dirname(request.outputPath), { recursive: true });
|
|
177
|
-
const pull = runAdb(serial, ["pull", remotePath, request.outputPath]);
|
|
178
|
-
if (pull.status !== 0) {
|
|
179
|
-
throw new Error(
|
|
180
|
-
`Could not pull Android recording from ${serial}: ${commandFailure(pull)}`
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
const output = fs.statSync(request.outputPath);
|
|
184
|
-
if (!output.isFile() || output.size <= 0) {
|
|
185
|
-
throw new Error(`Android recording output is empty: ${request.outputPath}`);
|
|
186
|
-
}
|
|
187
|
-
return {
|
|
188
|
-
...await optionalVideoTiming(request.outputPath, { startedAtUnixMs, stoppedAtUnixMs }),
|
|
189
|
-
recorder: {
|
|
190
|
-
name: "adb-screenrecord",
|
|
191
|
-
platform: "android",
|
|
192
|
-
target: { selector: "adb-serial", value: serial }
|
|
193
|
-
}
|
|
194
|
-
};
|
|
195
|
-
} finally {
|
|
196
|
-
runAdb(serial, ["shell", "rm", "-f", remotePath]);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
function androidTargetSerial(request) {
|
|
202
|
-
if (request.target.kind === "android-device") return request.target.serial;
|
|
203
|
-
if (request.target.kind !== "window-id") return void 0;
|
|
204
|
-
if (!request.target.windowId.startsWith(ANDROID_RECORDING_TARGET_PREFIX)) return void 0;
|
|
205
|
-
return request.target.windowId.slice(ANDROID_RECORDING_TARGET_PREFIX.length) || void 0;
|
|
206
|
-
}
|
|
207
|
-
async function waitForScreenrecordStart(serial, child, stderr) {
|
|
208
|
-
const deadline = Date.now() + RECORDING_START_TIMEOUT_MS;
|
|
209
|
-
while (Date.now() < deadline) {
|
|
210
|
-
if (child.exitCode !== null) {
|
|
211
|
-
throw new Error(
|
|
212
|
-
`Android screenrecord exited before capture started (exit ${child.exitCode}): ${stderr.join("").trim() || "no recorder output"}`
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
const pid = screenrecordPid(serial);
|
|
216
|
-
if (pid) return pid;
|
|
217
|
-
await sleep(100);
|
|
218
|
-
}
|
|
219
|
-
child.kill("SIGTERM");
|
|
220
|
-
throw new Error(
|
|
221
|
-
`Android screenrecord did not start on ${serial} within ${RECORDING_START_TIMEOUT_MS}ms.`
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
function stopRemoteScreenrecord(serial, pid) {
|
|
225
|
-
const stop = runAdb(serial, ["shell", "kill", "-2", pid]);
|
|
226
|
-
if (stop.status !== 0 && screenrecordPid(serial) === pid) {
|
|
227
|
-
throw new Error(`Could not stop Android screenrecord pid ${pid}: ${commandFailure(stop)}`);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
async function waitForChildExit(child, timeoutMs, recorderName) {
|
|
231
|
-
if (child.exitCode !== null) return;
|
|
232
|
-
await new Promise((resolve, reject) => {
|
|
233
|
-
const timeout = setTimeout(() => {
|
|
234
|
-
child.kill("SIGTERM");
|
|
235
|
-
reject(new Error(`${recorderName} did not stop within ${timeoutMs}ms.`));
|
|
236
|
-
}, timeoutMs);
|
|
237
|
-
child.once("exit", () => {
|
|
238
|
-
clearTimeout(timeout);
|
|
239
|
-
resolve();
|
|
240
|
-
});
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
async function waitForIosRecordingStart(child, output) {
|
|
244
|
-
const deadline = Date.now() + RECORDING_START_TIMEOUT_MS;
|
|
245
|
-
while (Date.now() < deadline) {
|
|
246
|
-
if (child.exitCode !== null) {
|
|
247
|
-
throw new Error(
|
|
248
|
-
`iOS simulator recordVideo exited before capture started (exit ${child.exitCode}): ${output.join("").trim() || "no recorder output"}`
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
if (output.join("").includes("Recording started")) return;
|
|
252
|
-
await sleep(100);
|
|
253
|
-
}
|
|
254
|
-
if (child.exitCode === null) return;
|
|
255
|
-
throw new Error(
|
|
256
|
-
`iOS simulator recordVideo did not start within ${RECORDING_START_TIMEOUT_MS}ms.`
|
|
257
|
-
);
|
|
258
|
-
}
|
|
259
|
-
function iosTargetSimulator(request) {
|
|
260
|
-
if (request.target.kind !== "window-id") return void 0;
|
|
261
|
-
if (!request.target.windowId.startsWith(
|
|
262
|
-
IOS_SIMULATOR_RECORDING_TARGET_PREFIX
|
|
263
|
-
)) {
|
|
264
|
-
return void 0;
|
|
265
|
-
}
|
|
266
|
-
return request.target.windowId.slice(IOS_SIMULATOR_RECORDING_TARGET_PREFIX.length) || void 0;
|
|
267
|
-
}
|
|
268
|
-
function screenrecordPid(serial) {
|
|
269
|
-
const result = runAdb(serial, ["shell", "pidof", "screenrecord"]);
|
|
270
|
-
if (result.status !== 0) return void 0;
|
|
271
|
-
return result.stdout.trim().split(/\s+/u).find(Boolean);
|
|
272
|
-
}
|
|
273
|
-
function runAdb(serial, args) {
|
|
274
|
-
return spawnSync("adb", ["-s", serial, ...args], { encoding: "utf8" });
|
|
275
|
-
}
|
|
276
|
-
function runSimctl(args) {
|
|
277
|
-
return spawnSync("xcrun", ["simctl", ...args], { encoding: "utf8" });
|
|
278
|
-
}
|
|
279
|
-
function commandFailure(result) {
|
|
280
|
-
return result.stderr.trim() || result.stdout.trim() || `exit ${result.status ?? "unknown"}`;
|
|
281
|
-
}
|
|
282
|
-
function sleep(durationMs) {
|
|
283
|
-
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
284
|
-
}
|
|
285
|
-
export {
|
|
286
|
-
androidScreenrecordArgs,
|
|
287
|
-
createAndroidVideoRecorder,
|
|
288
|
-
createIosSimulatorVideoRecorder
|
|
289
|
-
};
|