lynkeus 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/lib/__tests__/args.test.d.ts +1 -0
- package/lib/__tests__/args.test.js +69 -0
- package/lib/__tests__/check.test.d.ts +1 -0
- package/lib/__tests__/check.test.js +60 -0
- package/lib/__tests__/init.test.d.ts +1 -0
- package/lib/__tests__/init.test.js +42 -0
- package/lib/__tests__/record.test.d.ts +1 -0
- package/lib/__tests__/record.test.js +44 -0
- package/lib/__tests__/snapshot.test.d.ts +1 -0
- package/lib/__tests__/snapshot.test.js +47 -0
- package/lib/__tests__/targets.test.d.ts +1 -0
- package/lib/__tests__/targets.test.js +127 -0
- package/lib/args.d.ts +30 -0
- package/lib/args.js +86 -0
- package/lib/cases/__tests__/format.test.d.ts +1 -0
- package/lib/cases/__tests__/format.test.js +61 -0
- package/lib/cases/__tests__/runner.test.d.ts +1 -0
- package/lib/cases/__tests__/runner.test.js +188 -0
- package/lib/cases/fixtures.d.ts +13 -0
- package/lib/cases/fixtures.js +100 -0
- package/lib/cases/format.d.ts +37 -0
- package/lib/cases/format.js +169 -0
- package/lib/cases/runner.d.ts +61 -0
- package/lib/cases/runner.js +590 -0
- package/lib/cases/templates.d.ts +20 -0
- package/lib/cases/templates.js +156 -0
- package/lib/cli.d.ts +6 -0
- package/lib/cli.js +173 -0
- package/lib/commands/cases.d.ts +1 -0
- package/lib/commands/cases.js +179 -0
- package/lib/commands/check.d.ts +36 -0
- package/lib/commands/check.js +67 -0
- package/lib/commands/crawl/return.d.ts +20 -0
- package/lib/commands/crawl/return.js +125 -0
- package/lib/commands/crawl/state.d.ts +30 -0
- package/lib/commands/crawl/state.js +56 -0
- package/lib/commands/crawl.d.ts +67 -0
- package/lib/commands/crawl.js +213 -0
- package/lib/commands/device.d.ts +10 -0
- package/lib/commands/device.js +122 -0
- package/lib/commands/doctor.d.ts +3 -0
- package/lib/commands/doctor.js +240 -0
- package/lib/commands/drive.d.ts +1 -0
- package/lib/commands/drive.js +410 -0
- package/lib/commands/flake.d.ts +14 -0
- package/lib/commands/flake.js +42 -0
- package/lib/commands/headless.d.ts +1 -0
- package/lib/commands/headless.js +127 -0
- package/lib/commands/init.d.ts +13 -0
- package/lib/commands/init.js +85 -0
- package/lib/commands/map.d.ts +2 -0
- package/lib/commands/map.js +297 -0
- package/lib/commands/os.d.ts +1 -0
- package/lib/commands/os.js +193 -0
- package/lib/commands/overlay.d.ts +9 -0
- package/lib/commands/overlay.js +12 -0
- package/lib/commands/record.d.ts +5 -0
- package/lib/commands/record.js +55 -0
- package/lib/commands/runs.d.ts +1 -0
- package/lib/commands/runs.js +452 -0
- package/lib/commands/smoke.d.ts +28 -0
- package/lib/commands/smoke.js +59 -0
- package/lib/commands/snapshot.d.ts +27 -0
- package/lib/commands/snapshot.js +69 -0
- package/lib/config.d.ts +72 -0
- package/lib/config.js +44 -0
- package/lib/device/devices.d.ts +11 -0
- package/lib/device/devices.js +33 -0
- package/lib/device/driver.d.ts +53 -0
- package/lib/device/driver.js +169 -0
- package/lib/device/platform.d.ts +7 -0
- package/lib/device/platform.js +59 -0
- package/lib/files.d.ts +7 -0
- package/lib/files.js +29 -0
- package/lib/flow/artifacts.d.ts +19 -0
- package/lib/flow/artifacts.js +49 -0
- package/lib/flow/runner.d.ts +13 -0
- package/lib/flow/runner.js +169 -0
- package/lib/flow/types.d.ts +111 -0
- package/lib/flow/types.js +1 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +11 -0
- package/lib/knowledge/__tests__/edges.test.d.ts +1 -0
- package/lib/knowledge/__tests__/edges.test.js +25 -0
- package/lib/knowledge/__tests__/events.test.d.ts +1 -0
- package/lib/knowledge/__tests__/events.test.js +39 -0
- package/lib/knowledge/__tests__/extract.test.d.ts +1 -0
- package/lib/knowledge/__tests__/extract.test.js +69 -0
- package/lib/knowledge/__tests__/frontier.test.d.ts +1 -0
- package/lib/knowledge/__tests__/frontier.test.js +57 -0
- package/lib/knowledge/__tests__/funnels.test.d.ts +1 -0
- package/lib/knowledge/__tests__/funnels.test.js +40 -0
- package/lib/knowledge/__tests__/lint.test.d.ts +1 -0
- package/lib/knowledge/__tests__/lint.test.js +53 -0
- package/lib/knowledge/__tests__/perf.test.d.ts +1 -0
- package/lib/knowledge/__tests__/perf.test.js +34 -0
- package/lib/knowledge/__tests__/plan.test.d.ts +1 -0
- package/lib/knowledge/__tests__/plan.test.js +35 -0
- package/lib/knowledge/__tests__/query.test.d.ts +1 -0
- package/lib/knowledge/__tests__/query.test.js +55 -0
- package/lib/knowledge/__tests__/screens.test.d.ts +1 -0
- package/lib/knowledge/__tests__/screens.test.js +30 -0
- package/lib/knowledge/__tests__/why.test.d.ts +1 -0
- package/lib/knowledge/__tests__/why.test.js +28 -0
- package/lib/knowledge/edges.d.ts +30 -0
- package/lib/knowledge/edges.js +68 -0
- package/lib/knowledge/errors.d.ts +8 -0
- package/lib/knowledge/errors.js +62 -0
- package/lib/knowledge/events.d.ts +23 -0
- package/lib/knowledge/events.js +51 -0
- package/lib/knowledge/extract.d.ts +26 -0
- package/lib/knowledge/extract.js +365 -0
- package/lib/knowledge/frontier.d.ts +26 -0
- package/lib/knowledge/frontier.js +60 -0
- package/lib/knowledge/funnels.d.ts +46 -0
- package/lib/knowledge/funnels.js +103 -0
- package/lib/knowledge/lint.d.ts +16 -0
- package/lib/knowledge/lint.js +89 -0
- package/lib/knowledge/perf.d.ts +32 -0
- package/lib/knowledge/perf.js +91 -0
- package/lib/knowledge/plan.d.ts +22 -0
- package/lib/knowledge/plan.js +81 -0
- package/lib/knowledge/problems.d.ts +5 -0
- package/lib/knowledge/problems.js +8 -0
- package/lib/knowledge/query.d.ts +36 -0
- package/lib/knowledge/query.js +127 -0
- package/lib/knowledge/route-params.d.ts +6 -0
- package/lib/knowledge/route-params.js +42 -0
- package/lib/knowledge/screens.d.ts +30 -0
- package/lib/knowledge/screens.js +125 -0
- package/lib/knowledge/tree.d.ts +15 -0
- package/lib/knowledge/tree.js +63 -0
- package/lib/knowledge/types.d.ts +62 -0
- package/lib/knowledge/types.js +6 -0
- package/lib/knowledge/why.d.ts +24 -0
- package/lib/knowledge/why.js +153 -0
- package/lib/mcp/server.d.ts +4 -0
- package/lib/mcp/server.js +111 -0
- package/lib/registry.d.ts +78 -0
- package/lib/registry.js +113 -0
- package/lib/runtime.d.ts +12 -0
- package/lib/runtime.js +56 -0
- package/lib/targets.d.ts +17 -0
- package/lib/targets.js +43 -0
- package/package.json +47 -0
- package/src/__tests__/args.test.ts +82 -0
- package/src/__tests__/check.test.ts +71 -0
- package/src/__tests__/init.test.ts +52 -0
- package/src/__tests__/record.test.ts +49 -0
- package/src/__tests__/snapshot.test.ts +51 -0
- package/src/__tests__/targets.test.ts +132 -0
- package/src/args.ts +111 -0
- package/src/cases/__tests__/format.test.ts +76 -0
- package/src/cases/__tests__/runner.test.ts +230 -0
- package/src/cases/fixtures.ts +104 -0
- package/src/cases/format.ts +183 -0
- package/src/cases/runner.ts +575 -0
- package/src/cases/templates.ts +159 -0
- package/src/cli.ts +176 -0
- package/src/commands/cases.ts +170 -0
- package/src/commands/check.ts +83 -0
- package/src/commands/crawl/return.ts +130 -0
- package/src/commands/crawl/state.ts +70 -0
- package/src/commands/crawl.ts +262 -0
- package/src/commands/device.ts +119 -0
- package/src/commands/doctor.ts +237 -0
- package/src/commands/drive.ts +413 -0
- package/src/commands/flake.ts +48 -0
- package/src/commands/headless.ts +131 -0
- package/src/commands/init.ts +98 -0
- package/src/commands/map.ts +298 -0
- package/src/commands/os.ts +195 -0
- package/src/commands/overlay.ts +20 -0
- package/src/commands/record.ts +46 -0
- package/src/commands/runs.ts +475 -0
- package/src/commands/smoke.ts +86 -0
- package/src/commands/snapshot.ts +87 -0
- package/src/config.ts +103 -0
- package/src/device/devices.ts +44 -0
- package/src/device/driver.ts +197 -0
- package/src/device/platform.ts +74 -0
- package/src/files.ts +35 -0
- package/src/flow/artifacts.ts +61 -0
- package/src/flow/runner.ts +182 -0
- package/src/flow/types.ts +55 -0
- package/src/index.ts +18 -0
- package/src/knowledge/__tests__/edges.test.ts +34 -0
- package/src/knowledge/__tests__/events.test.ts +45 -0
- package/src/knowledge/__tests__/extract.test.ts +88 -0
- package/src/knowledge/__tests__/frontier.test.ts +77 -0
- package/src/knowledge/__tests__/funnels.test.ts +58 -0
- package/src/knowledge/__tests__/lint.test.ts +66 -0
- package/src/knowledge/__tests__/perf.test.ts +41 -0
- package/src/knowledge/__tests__/plan.test.ts +52 -0
- package/src/knowledge/__tests__/query.test.ts +74 -0
- package/src/knowledge/__tests__/screens.test.ts +37 -0
- package/src/knowledge/__tests__/why.test.ts +40 -0
- package/src/knowledge/edges.ts +91 -0
- package/src/knowledge/errors.ts +73 -0
- package/src/knowledge/events.ts +60 -0
- package/src/knowledge/extract.ts +379 -0
- package/src/knowledge/frontier.ts +82 -0
- package/src/knowledge/funnels.ts +126 -0
- package/src/knowledge/lint.ts +99 -0
- package/src/knowledge/perf.ts +114 -0
- package/src/knowledge/plan.ts +95 -0
- package/src/knowledge/problems.ts +12 -0
- package/src/knowledge/query.ts +146 -0
- package/src/knowledge/route-params.ts +39 -0
- package/src/knowledge/screens.ts +139 -0
- package/src/knowledge/tree.ts +66 -0
- package/src/knowledge/types.ts +60 -0
- package/src/knowledge/why.ts +189 -0
- package/src/mcp/server.ts +109 -0
- package/src/registry.ts +191 -0
- package/src/runtime.ts +54 -0
- package/src/targets.ts +44 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which native modules the app's source references that nothing mocks yet:
|
|
3
|
+
* "a crash deep in a React stack" turned into "these modules need a mock",
|
|
4
|
+
* statically, without a simulator or a host.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { packageName } from '../config.js';
|
|
10
|
+
|
|
11
|
+
// What lynkeus-headless mocks, read out of the installed package when there is one (importing it would pull in
|
|
12
|
+
// React Native); this copy is only right for the version it shipped with.
|
|
13
|
+
const AUTO_MOCKED = [
|
|
14
|
+
'react-native-mmkv',
|
|
15
|
+
'react-native-keychain',
|
|
16
|
+
'react-native-device-info',
|
|
17
|
+
'react-native-safe-area-context',
|
|
18
|
+
'react-native-localize',
|
|
19
|
+
'@react-native-async-storage/async-storage',
|
|
20
|
+
'react-native-worklets',
|
|
21
|
+
'react-native-pager-view',
|
|
22
|
+
'react-native-video',
|
|
23
|
+
'react-native-fast-image',
|
|
24
|
+
'react-native-biometrics',
|
|
25
|
+
'react-native-in-app-review',
|
|
26
|
+
'react-native-webview',
|
|
27
|
+
'react-native-vision-camera',
|
|
28
|
+
'react-native-image-picker',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Native modules React Native's own jest preset already provides.
|
|
32
|
+
const PRESET_PROVIDED = new Set([
|
|
33
|
+
'SettingsManager',
|
|
34
|
+
'DeviceInfo',
|
|
35
|
+
'PlatformConstants',
|
|
36
|
+
'I18nManager',
|
|
37
|
+
'StatusBarManager',
|
|
38
|
+
'Appearance',
|
|
39
|
+
'DevSettings',
|
|
40
|
+
'SourceCode',
|
|
41
|
+
'Timing',
|
|
42
|
+
'UIManager',
|
|
43
|
+
'ImageLoader',
|
|
44
|
+
'KeyboardObserver',
|
|
45
|
+
'LinkingManager',
|
|
46
|
+
'NativeAnimatedModule',
|
|
47
|
+
'NativeUnimoduleProxy',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
type NativeRef = { name: string; file: string; line: number; how: string };
|
|
51
|
+
|
|
52
|
+
const SCAN_DIRS = ['app', 'src', 'modules', 'components', 'screens'];
|
|
53
|
+
const SKIP = new Set(['node_modules', '.git', 'ios', 'android', 'lib', 'build', '.lynkeus', '__tests__', '__mocks__']);
|
|
54
|
+
const CODE = /\.[cm]?[jt]sx?$/;
|
|
55
|
+
|
|
56
|
+
const walk = (dir: string, out: string[]): void => {
|
|
57
|
+
let entries: fs.Dirent[];
|
|
58
|
+
try {
|
|
59
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const e of entries) {
|
|
64
|
+
if (e.name.startsWith('.') && e.name !== '.') continue;
|
|
65
|
+
const full = path.join(dir, e.name);
|
|
66
|
+
if (e.isDirectory()) {
|
|
67
|
+
if (!SKIP.has(e.name)) walk(full, out);
|
|
68
|
+
} else if (CODE.test(e.name)) {
|
|
69
|
+
out.push(full);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const PATTERNS: { re: RegExp; how: string }[] = [
|
|
75
|
+
{ re: /NativeModules\.(\w+)/g, how: 'NativeModules' },
|
|
76
|
+
{ re: /TurboModuleRegistry\.get(?:Enforcing)?\(\s*['"]([^'"]+)['"]/g, how: 'TurboModuleRegistry' },
|
|
77
|
+
{ re: /requireNativeComponent\s*(?:<[^>]*>)?\(\s*['"]([^'"]+)['"]/g, how: 'requireNativeComponent' },
|
|
78
|
+
{ re: /codegenNativeComponent\s*<[^>]*>\(\s*['"]([^'"]+)['"]/g, how: 'codegenNativeComponent' },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const scanNativeRefs = (root: string): NativeRef[] => {
|
|
82
|
+
const files: string[] = [];
|
|
83
|
+
for (const d of SCAN_DIRS) walk(path.join(root, d), files);
|
|
84
|
+
const seen = new Map<string, NativeRef>();
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
let text: string;
|
|
87
|
+
try {
|
|
88
|
+
text = fs.readFileSync(file, 'utf8');
|
|
89
|
+
} catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
for (const { re, how } of PATTERNS) {
|
|
93
|
+
for (const m of text.matchAll(re)) {
|
|
94
|
+
const name = m[1]!;
|
|
95
|
+
if (PRESET_PROVIDED.has(name) || seen.has(name)) continue;
|
|
96
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
97
|
+
seen.set(name, { name, file: path.relative(root, file), line, how });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const entryMocks = (root: string, entry: string): { specs: Set<string>; files: Set<string>; network?: string } => {
|
|
105
|
+
const specs = new Set<string>();
|
|
106
|
+
const files = new Set<string>();
|
|
107
|
+
const entryPath = path.join(root, entry);
|
|
108
|
+
let text = '';
|
|
109
|
+
try {
|
|
110
|
+
text = fs.readFileSync(entryPath, 'utf8');
|
|
111
|
+
} catch {
|
|
112
|
+
return { specs, files };
|
|
113
|
+
}
|
|
114
|
+
const dir = path.dirname(entryPath);
|
|
115
|
+
const jestMock = /jest\.(?:do)?mock\(\s*['"]([^'"]+)['"]/g;
|
|
116
|
+
for (const m of text.matchAll(jestMock)) {
|
|
117
|
+
const spec = m[1]!;
|
|
118
|
+
specs.add(spec);
|
|
119
|
+
if (spec.startsWith('.')) files.add(path.relative(root, path.resolve(dir, spec)));
|
|
120
|
+
}
|
|
121
|
+
const overrides = /['"]([^'"]+)['"]\s*:\s*(?:\([^)]*\)|function)/g; // rough: keys in overrides map
|
|
122
|
+
for (const m of text.matchAll(overrides)) specs.add(m[1]!);
|
|
123
|
+
const network = /network\s*:\s*['"](real|banned)['"]/.exec(text)?.[1];
|
|
124
|
+
return { specs, files, network };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const autoMockedFrom = (root: string): string[] => {
|
|
128
|
+
for (const rel of ['node_modules/lynkeus-headless/lib/mocks/install.js', 'node_modules/lynkeus-headless/src/mocks/install.ts']) {
|
|
129
|
+
try {
|
|
130
|
+
const src = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
131
|
+
const defaults = src.match(/defaults[^=]*=\s*\{([\s\S]*?)\n\s*\};/)?.[1];
|
|
132
|
+
const names = [...(defaults ?? '').matchAll(/^\s*'([^']+)':/gm)].flatMap((m) => (m[1] ? [m[1]] : []));
|
|
133
|
+
if (names.length > 0) return names;
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
return AUTO_MOCKED;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const doctor = (root: string, entry: string, options: { fix?: boolean } = {}): string[] => {
|
|
140
|
+
let pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> } = {};
|
|
141
|
+
try {
|
|
142
|
+
pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
143
|
+
} catch {}
|
|
144
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
145
|
+
|
|
146
|
+
const autoMocked = autoMockedFrom(root);
|
|
147
|
+
const present = autoMocked.filter((m) => m in deps);
|
|
148
|
+
const entryHasFile = fs.existsSync(path.join(root, entry));
|
|
149
|
+
const { specs, files, network } = entryHasFile ? entryMocks(root, entry) : { specs: new Set<string>(), files: new Set<string>(), network: undefined };
|
|
150
|
+
const refs = scanNativeRefs(root);
|
|
151
|
+
|
|
152
|
+
// Inside a package installDefaultMocks() covers, the app never reaches the raw native binding.
|
|
153
|
+
const insideAutoMocked = (ref: NativeRef) => present.some((m) => ref.file.includes(`node_modules/${m}/`) || ref.file.startsWith(`${m}/`));
|
|
154
|
+
const covered = (ref: NativeRef): boolean =>
|
|
155
|
+
files.has(ref.file) || specs.has(ref.name) || insideAutoMocked(ref) || [...specs].some((s) => ref.file.includes(s.replace(/^\.\.?\//, '')));
|
|
156
|
+
const uncovered = refs.filter((r) => !covered(r));
|
|
157
|
+
|
|
158
|
+
const lines: string[] = [];
|
|
159
|
+
lines.push(`lynkeus headless doctor — headless readiness for ${packageName(root)}\n`);
|
|
160
|
+
|
|
161
|
+
lines.push('Auto-mocked by lynkeus (installDefaultMocks):');
|
|
162
|
+
if (present.length) for (const m of present) lines.push(` ✓ ${m}`);
|
|
163
|
+
else lines.push(' (none of the known native libs are installed)');
|
|
164
|
+
lines.push('');
|
|
165
|
+
|
|
166
|
+
if (!entryHasFile) {
|
|
167
|
+
lines.push(`No headless entry at ${entry}.`);
|
|
168
|
+
lines.push(' Create one that installs the default mocks and exports App:');
|
|
169
|
+
lines.push(" import { installDefaultMocks } from 'lynkeus-headless/mocks'");
|
|
170
|
+
lines.push(" installDefaultMocks({ network: 'real' })");
|
|
171
|
+
lines.push(" const { default: App } = require('./path/to/App')");
|
|
172
|
+
lines.push(' export { App }');
|
|
173
|
+
lines.push('');
|
|
174
|
+
} else {
|
|
175
|
+
lines.push(`Mocked in ${entry} (app-specific):`);
|
|
176
|
+
const appMocks = [...specs].filter((s) => !autoMocked.includes(s));
|
|
177
|
+
if (appMocks.length) for (const s of appMocks) lines.push(` ✓ ${s}`);
|
|
178
|
+
else lines.push(' (none beyond the lynkeus defaults)');
|
|
179
|
+
lines.push(` network: ${network ?? "default (jest ban) — pass { network: 'real' } to reach a backend"}`);
|
|
180
|
+
lines.push('');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The agent ships ESM; a jest config that only transforms react-native-* never parses it.
|
|
184
|
+
const jestConfig = ['jest.config.js', 'jest.config.ts', 'jest.config.cjs', 'jest.config.mjs'].map((f) => path.join(root, f)).find((f) => fs.existsSync(f));
|
|
185
|
+
if (jestConfig) {
|
|
186
|
+
const text = fs.readFileSync(jestConfig, 'utf8');
|
|
187
|
+
if (/transformIgnorePatterns/.test(text) && !/lynkeus/.test(text)) {
|
|
188
|
+
lines.push(`jest: ${path.basename(jestConfig)} has transformIgnorePatterns without lynkeus in its allow-list.`);
|
|
189
|
+
lines.push(" Add 'lynkeus-[^/]+' next to react-native, or the host stops at \"Unexpected token 'export'\" in lynkeus-agent.");
|
|
190
|
+
lines.push('');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
lines.push("Native modules your app's source references:");
|
|
194
|
+
if (refs.length === 0) {
|
|
195
|
+
lines.push(' (none found by the static scan)');
|
|
196
|
+
} else {
|
|
197
|
+
for (const r of refs) {
|
|
198
|
+
const mark = covered(r) ? '✓' : '⚠';
|
|
199
|
+
lines.push(` ${mark} ${r.name.padEnd(28)} ${r.file}:${r.line} (${r.how})`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
lines.push('');
|
|
203
|
+
|
|
204
|
+
if (uncovered.length) {
|
|
205
|
+
lines.push(`${uncovered.length} native module(s) look unmocked. Under jest they do not exist, so`);
|
|
206
|
+
lines.push(`the app can crash when it touches them. Add a mock for each in ${entry}:`);
|
|
207
|
+
for (const r of uncovered) lines.push(` jest.mock('...${r.name}...', () => ({ /* the methods your app calls */ }))`);
|
|
208
|
+
lines.push('');
|
|
209
|
+
lines.push('(Heuristic: some may be provided by a mocked wrapper or a library. Review.)');
|
|
210
|
+
if (options.fix) {
|
|
211
|
+
// `{}` gets past the import; a method the app then calls fails with a name `doctor --explain` understands.
|
|
212
|
+
const packages = [...new Set(uncovered.map((r) => r.file.match(/node_modules\/((?:@[^/]+\/)?[^/]+)/)?.[1]).filter((p): p is string => !!p))];
|
|
213
|
+
const local = uncovered.filter((r) => !r.file.includes('node_modules/'));
|
|
214
|
+
const block = [
|
|
215
|
+
'',
|
|
216
|
+
'// Added by `lynkeus headless doctor --fix`: stubs for native modules nothing covered.',
|
|
217
|
+
'// Replace {} with the methods the app calls; `lynkeus headless doctor` re-checks.',
|
|
218
|
+
...packages.map((p) => `jest.mock('${p}', () => ({}))`),
|
|
219
|
+
...local.map((r) => `// TODO ${r.name} (${r.file}:${r.line}, ${r.how}): app-local, mock the module that wraps it`),
|
|
220
|
+
'',
|
|
221
|
+
].join('\n');
|
|
222
|
+
if (entryHasFile) {
|
|
223
|
+
const entryPath = path.join(root, entry);
|
|
224
|
+
const text = fs.readFileSync(entryPath, 'utf8');
|
|
225
|
+
const at = text.indexOf('const { default: App }');
|
|
226
|
+
fs.writeFileSync(entryPath, at >= 0 ? `${text.slice(0, at) + block.trimStart()}\n${text.slice(at)}` : text + block);
|
|
227
|
+
lines.push(`--fix: wrote ${packages.length} stub(s) and ${local.length} TODO(s) into ${entry}`);
|
|
228
|
+
} else {
|
|
229
|
+
lines.push('--fix: no entry to write into; run `lynkeus headless init` first');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} else if (refs.length) {
|
|
233
|
+
lines.push('Every referenced native module appears covered. ✓');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return lines;
|
|
237
|
+
};
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { lynkeusDir } from '../config.js';
|
|
5
|
+
import { describeScreen, describeTrace } from '../device/driver.js';
|
|
6
|
+
import { readJson, writeJson } from '../files.js';
|
|
7
|
+
import { describeFindings, lintScreen } from '../knowledge/lint.js';
|
|
8
|
+
import { describePerf, summarize, visitsFrom } from '../knowledge/perf.js';
|
|
9
|
+
import * as screens from '../knowledge/screens.js';
|
|
10
|
+
import { screenTree } from '../knowledge/tree.js';
|
|
11
|
+
import { define } from '../registry.js';
|
|
12
|
+
import { whyFor } from '../runtime.js';
|
|
13
|
+
import { expectedOf, isRoute, parseTarget, scopedTarget, TARGET_HELP } from '../targets.js';
|
|
14
|
+
import { type LintBaseline, newFindings } from './check.js';
|
|
15
|
+
|
|
16
|
+
const SCOPE_FLAGS = {
|
|
17
|
+
in: { type: 'string' as const, help: 'the match nearest this target', value: 'target' },
|
|
18
|
+
nth: { type: 'number' as const, help: 'the nth match, from 0', value: 'n' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const driveCommands = [
|
|
22
|
+
define({
|
|
23
|
+
name: 'screen',
|
|
24
|
+
mcp: 'core',
|
|
25
|
+
mcpOmit: ['record', 'tree', 'verbose', 'frames'],
|
|
26
|
+
mcpDetails: 'One line per element: index, kind, #testId, label, value, state (!disabled ~covered ~inert). The header counts what is not presented.',
|
|
27
|
+
group: 'drive',
|
|
28
|
+
summary: 'Route, path and every element on screen, in ms',
|
|
29
|
+
details:
|
|
30
|
+
'One line per element: index, kind, #testId, label, value and state (`!disabled`, `~covered`, `~inert`). What the platform is not presenting is left out and counted in the header; `--all` lists it. `--frames` adds each centre in points; `--verbose` the commit count and cost. `--tree` prints nested lines with no geometry: the form to diff. `--record` keeps the structure under .lynkeus/screens/ and notes what changed since the last read.',
|
|
31
|
+
flags: {
|
|
32
|
+
tree: { type: 'boolean', help: 'as a tree of meaning, without frames' },
|
|
33
|
+
record: { type: 'boolean', help: 'keep it under .lynkeus/screens/ and report changes (or LYNKEUS_RECORD_SCREENS=1)' },
|
|
34
|
+
all: { type: 'boolean', help: 'also list what is not presented' },
|
|
35
|
+
frames: { type: 'boolean', help: 'with each centre, in points' },
|
|
36
|
+
verbose: { type: 'boolean', help: 'commit count and read cost in the header' },
|
|
37
|
+
},
|
|
38
|
+
needs: 'app',
|
|
39
|
+
run: async (ctx) => {
|
|
40
|
+
const screen = await (await ctx.device()).screen();
|
|
41
|
+
if (ctx.flags.record || process.env.LYNKEUS_RECORD_SCREENS === '1') {
|
|
42
|
+
const change = screens.record(lynkeusDir(ctx.root), screen);
|
|
43
|
+
if (change) ctx.err(`screen changed since last read:\n${screens.describeChange(change)}`);
|
|
44
|
+
}
|
|
45
|
+
const options = { all: Boolean(ctx.flags.all), frames: Boolean(ctx.flags.frames), verbose: Boolean(ctx.flags.verbose) };
|
|
46
|
+
return { text: ctx.flags.tree ? screenTree(screen) : describeScreen(screen, options), json: screen };
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
define({
|
|
50
|
+
name: 'press',
|
|
51
|
+
mcp: 'core',
|
|
52
|
+
mcpOmit: ['hold', 'js', 'nth'],
|
|
53
|
+
group: 'drive',
|
|
54
|
+
summary: 'A finger on an element',
|
|
55
|
+
positionals: [{ name: 'target', help: TARGET_HELP, required: true }],
|
|
56
|
+
flags: {
|
|
57
|
+
...SCOPE_FLAGS,
|
|
58
|
+
hold: { type: 'number', help: 'hold for this long', value: 'ms' },
|
|
59
|
+
js: { type: 'boolean', help: 'call onPress directly instead of a synthesized touch' },
|
|
60
|
+
settle: { type: 'boolean', help: 'wait for the app to stop committing, then print the screen' },
|
|
61
|
+
},
|
|
62
|
+
needs: 'app',
|
|
63
|
+
run: async (ctx) => {
|
|
64
|
+
const device = await ctx.device();
|
|
65
|
+
const result = await device.press({
|
|
66
|
+
...scopedTarget(ctx.args[0]!, ctx.flags),
|
|
67
|
+
holdMs: ctx.flags.hold as number | undefined,
|
|
68
|
+
mode: ctx.flags.js ? 'js' : undefined,
|
|
69
|
+
});
|
|
70
|
+
if (!ctx.flags.settle) return { json: result };
|
|
71
|
+
await device.idle({ quietMs: 200, timeoutMs: 3000 }).catch(() => undefined);
|
|
72
|
+
return { text: describeScreen(await device.screen()), json: result };
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
define({
|
|
76
|
+
name: 'type',
|
|
77
|
+
mcp: 'core',
|
|
78
|
+
mcpOmit: ['paste', 'nth'],
|
|
79
|
+
group: 'drive',
|
|
80
|
+
summary: 'Type into the focused input, or tap a target first',
|
|
81
|
+
positionals: [
|
|
82
|
+
{ name: 'text', help: 'what to type', required: true },
|
|
83
|
+
{ name: 'target', help: `tap this first: ${TARGET_HELP}` },
|
|
84
|
+
],
|
|
85
|
+
flags: {
|
|
86
|
+
...SCOPE_FLAGS,
|
|
87
|
+
paste: { type: 'boolean', help: 'insert at once instead of key by key' },
|
|
88
|
+
submit: { type: 'boolean', help: 'then the return key (onSubmitEditing)' },
|
|
89
|
+
clear: { type: 'boolean', help: 'clear the field first' },
|
|
90
|
+
},
|
|
91
|
+
needs: 'app',
|
|
92
|
+
run: async (ctx) => ({
|
|
93
|
+
json: await (await ctx.device()).type({
|
|
94
|
+
text: ctx.args[0]!,
|
|
95
|
+
target: ctx.args[1] ? scopedTarget(ctx.args[1], ctx.flags) : undefined,
|
|
96
|
+
paste: !!ctx.flags.paste,
|
|
97
|
+
clear: !!ctx.flags.clear,
|
|
98
|
+
submit: !!ctx.flags.submit,
|
|
99
|
+
}),
|
|
100
|
+
}),
|
|
101
|
+
}),
|
|
102
|
+
define({
|
|
103
|
+
name: 'swipe',
|
|
104
|
+
group: 'drive',
|
|
105
|
+
summary: 'A drag; on a target, drives its gesture (a slider, a sheet)',
|
|
106
|
+
details:
|
|
107
|
+
'On a target the drag starts at its centre, which a wide control cannot always afford: a slider needing 80% of its width would end off-screen, and a touch that leaves the screen is cancelled. `--from`/`--to` place both ends, in points.',
|
|
108
|
+
positionals: [
|
|
109
|
+
{ name: 'direction', help: 'up | down | left | right (omit when giving --from/--to)' },
|
|
110
|
+
{ name: 'target', help: TARGET_HELP },
|
|
111
|
+
],
|
|
112
|
+
flags: {
|
|
113
|
+
distance: { type: 'number', help: 'how far, in points', value: 'pt' },
|
|
114
|
+
from: { type: 'string', help: 'where the finger lands, as x,y', value: 'x,y' },
|
|
115
|
+
to: { type: 'string', help: 'where it lifts, as x,y', value: 'x,y' },
|
|
116
|
+
duration: { type: 'number', help: 'how long the drag takes, ms', value: 'ms' },
|
|
117
|
+
},
|
|
118
|
+
needs: 'app',
|
|
119
|
+
run: async (ctx) => {
|
|
120
|
+
const device = await ctx.device();
|
|
121
|
+
const durationMs = ctx.flags.duration as number | undefined;
|
|
122
|
+
const point = (flag: 'from' | 'to') => {
|
|
123
|
+
const [x, y] = (ctx.flags[flag] as string).split(',').map((n) => Number(n.trim()));
|
|
124
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error(`--${flag} must be two numbers, as x,y`);
|
|
125
|
+
return { x: x!, y: y! };
|
|
126
|
+
};
|
|
127
|
+
if (ctx.flags.from || ctx.flags.to) {
|
|
128
|
+
if (!ctx.flags.from || !ctx.flags.to) throw new Error('--from and --to go together');
|
|
129
|
+
return { json: await device.server.call('swipe', { from: point('from'), to: point('to'), durationMs }) };
|
|
130
|
+
}
|
|
131
|
+
const direction = ctx.args[0] as 'up' | 'down' | 'left' | 'right';
|
|
132
|
+
if (!['up', 'down', 'left', 'right'].includes(direction)) throw new Error('swipe needs a direction (up, down, left or right), or --from and --to');
|
|
133
|
+
if (ctx.args[1]) {
|
|
134
|
+
const distance = ctx.flags.distance as number | undefined;
|
|
135
|
+
return { json: await device.server.call('swipe', { direction, target: parseTarget(ctx.args[1]), distance, durationMs }) };
|
|
136
|
+
}
|
|
137
|
+
await device.swipe({ direction });
|
|
138
|
+
return { json: { direction } };
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
define({
|
|
142
|
+
name: 'back',
|
|
143
|
+
group: 'drive',
|
|
144
|
+
summary: 'Pop the current route',
|
|
145
|
+
needs: 'app',
|
|
146
|
+
run: async (ctx) => ({ json: await (await ctx.device()).back() }),
|
|
147
|
+
}),
|
|
148
|
+
define({
|
|
149
|
+
name: 'nav',
|
|
150
|
+
group: 'drive',
|
|
151
|
+
summary: 'Jump straight to a route (a teleport, not a user action)',
|
|
152
|
+
positionals: [
|
|
153
|
+
{ name: 'route', help: 'e.g. Settings.Main', required: true },
|
|
154
|
+
{ name: 'params', help: 'JSON params' },
|
|
155
|
+
],
|
|
156
|
+
needs: 'app',
|
|
157
|
+
run: async (ctx) => {
|
|
158
|
+
await (await ctx.device()).navigate(ctx.args[0]!, ctx.args[1] ? JSON.parse(ctx.args[1]) : undefined);
|
|
159
|
+
return { json: { route: ctx.args[0] } };
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
define({
|
|
163
|
+
name: 'wait',
|
|
164
|
+
mcp: 'core',
|
|
165
|
+
mcpOmit: ['nth'],
|
|
166
|
+
group: 'drive',
|
|
167
|
+
summary: 'Until a route or element appears (or leaves), or a number of ms',
|
|
168
|
+
positionals: [{ name: 'what', help: `Route | ${TARGET_HELP} | 800 (ms)`, required: true }],
|
|
169
|
+
flags: {
|
|
170
|
+
...SCOPE_FLAGS,
|
|
171
|
+
gone: { type: 'boolean', help: 'until it leaves the screen' },
|
|
172
|
+
scroll: { type: 'boolean', help: 'scroll until it is in the window' },
|
|
173
|
+
timeout: { type: 'number', help: 'give up after', default: 10000, value: 'ms' },
|
|
174
|
+
},
|
|
175
|
+
needs: 'app',
|
|
176
|
+
run: async (ctx) => {
|
|
177
|
+
const raw = ctx.args[0]!;
|
|
178
|
+
if (/^\d+$/.test(raw)) {
|
|
179
|
+
await new Promise((r) => setTimeout(r, Number(raw)));
|
|
180
|
+
return { json: { waitedMs: Number(raw) } };
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
json: await (await ctx.device()).waitFor({
|
|
184
|
+
...(isRoute(raw) ? { route: raw } : { target: scopedTarget(raw, ctx.flags) }),
|
|
185
|
+
gone: ctx.flags.gone ? true : undefined,
|
|
186
|
+
scroll: ctx.flags.scroll ? true : undefined,
|
|
187
|
+
timeoutMs: ctx.flags.timeout as number,
|
|
188
|
+
}),
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
}),
|
|
192
|
+
define({
|
|
193
|
+
name: 'screenshot',
|
|
194
|
+
group: 'drive',
|
|
195
|
+
summary: 'What the screen looks like right now, written to a file',
|
|
196
|
+
details: 'A device or simulator only: a host renders nothing to capture.',
|
|
197
|
+
positionals: [{ name: 'file', help: 'where to write the png', required: true }],
|
|
198
|
+
needs: 'app',
|
|
199
|
+
run: async (ctx) => {
|
|
200
|
+
const file = path.resolve(ctx.args[0]!);
|
|
201
|
+
await (await ctx.device()).screenshot(file);
|
|
202
|
+
return { text: file, json: { file } };
|
|
203
|
+
},
|
|
204
|
+
}),
|
|
205
|
+
define({
|
|
206
|
+
name: 'see',
|
|
207
|
+
group: 'drive',
|
|
208
|
+
summary: 'Assert a route or element is on screen now (exit 1 if not)',
|
|
209
|
+
positionals: [{ name: 'what', help: `Route | ${TARGET_HELP}`, required: true }],
|
|
210
|
+
flags: {
|
|
211
|
+
absent: { type: 'boolean', help: 'assert it is not there' },
|
|
212
|
+
within: { type: 'number', help: 'allow it this long to appear', default: 0, value: 'ms' },
|
|
213
|
+
},
|
|
214
|
+
needs: 'app',
|
|
215
|
+
run: async (ctx) => {
|
|
216
|
+
const raw = ctx.args[0]!;
|
|
217
|
+
const device = await ctx.device();
|
|
218
|
+
try {
|
|
219
|
+
const result = await device.waitFor({
|
|
220
|
+
...(isRoute(raw) ? { route: raw } : { target: parseTarget(raw) }),
|
|
221
|
+
gone: ctx.flags.absent ? true : undefined,
|
|
222
|
+
timeoutMs: Math.max(1, ctx.flags.within as number),
|
|
223
|
+
});
|
|
224
|
+
return { text: `${ctx.flags.absent ? 'gone' : 'on screen'}: ${raw}`, json: result };
|
|
225
|
+
} catch {
|
|
226
|
+
return { text: `${ctx.flags.absent ? 'still on screen' : 'not on screen'}: ${raw}`, json: { ok: false }, code: 1 };
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
define({
|
|
231
|
+
name: 'idle',
|
|
232
|
+
group: 'drive',
|
|
233
|
+
summary: 'Until the app stops committing: data arrived, animations done',
|
|
234
|
+
details:
|
|
235
|
+
'A screen that has not started looks exactly like one that has finished — both are quiet. `--after <commit>`, given the commit count from before whatever was supposed to happen, waits for the app to commit past it first.',
|
|
236
|
+
flags: {
|
|
237
|
+
quiet: { type: 'number', help: 'no commit for this long counts as settled', default: 300, value: 'ms' },
|
|
238
|
+
timeout: { type: 'number', help: 'give up after (the answer says whether it settled)', default: 5000, value: 'ms' },
|
|
239
|
+
after: { type: 'number', help: 'a commit count from before: quiet only counts once the app has committed past it', value: 'commit' },
|
|
240
|
+
},
|
|
241
|
+
needs: 'app',
|
|
242
|
+
run: async (ctx) => ({
|
|
243
|
+
json: await (await ctx.device()).idle({
|
|
244
|
+
quietMs: ctx.flags.quiet as number,
|
|
245
|
+
timeoutMs: ctx.flags.timeout as number,
|
|
246
|
+
after: ctx.flags.after as number | undefined,
|
|
247
|
+
}),
|
|
248
|
+
}),
|
|
249
|
+
}),
|
|
250
|
+
define({
|
|
251
|
+
name: 'requests',
|
|
252
|
+
group: 'drive',
|
|
253
|
+
summary: 'The HTTP requests the app made: method, path, status, ms',
|
|
254
|
+
flags: { last: { type: 'number', help: 'how many', default: 30, value: 'n' } },
|
|
255
|
+
needs: 'app',
|
|
256
|
+
run: async (ctx) => {
|
|
257
|
+
const result = await (await ctx.device()).requests();
|
|
258
|
+
const list = result.requests.slice(-(ctx.flags.last as number));
|
|
259
|
+
const lines = [
|
|
260
|
+
`in flight: ${result.inFlight}`,
|
|
261
|
+
...list.map(
|
|
262
|
+
(r) => `${String(r.status ?? '…').padStart(4)} ${String(r.ms ?? '').padStart(6)}ms ${r.method.padEnd(6)} ${r.url}${r.error ? ` (${r.error})` : ''}`,
|
|
263
|
+
),
|
|
264
|
+
];
|
|
265
|
+
return { text: lines.join('\n'), json: { inFlight: result.inFlight, requests: list } };
|
|
266
|
+
},
|
|
267
|
+
}),
|
|
268
|
+
define({
|
|
269
|
+
name: 'trace',
|
|
270
|
+
group: 'drive',
|
|
271
|
+
summary: 'What happened, in order: routes, requests, store diffs, touches, commands',
|
|
272
|
+
flags: {
|
|
273
|
+
since: { type: 'number', help: 'only after this seq (from a previous trace)', value: 'seq' },
|
|
274
|
+
last: { type: 'number', help: 'how many', default: 60, value: 'n' },
|
|
275
|
+
},
|
|
276
|
+
needs: 'app',
|
|
277
|
+
run: async (ctx) => {
|
|
278
|
+
const result = await (await ctx.device()).trace(ctx.flags.since as number | undefined);
|
|
279
|
+
const events = result.events.slice(-(ctx.flags.last as number));
|
|
280
|
+
ctx.err(`last seq ${result.last}`);
|
|
281
|
+
return { text: describeTrace(events), json: { last: result.last, events } };
|
|
282
|
+
},
|
|
283
|
+
}),
|
|
284
|
+
define({
|
|
285
|
+
name: 'call',
|
|
286
|
+
mcp: 'core',
|
|
287
|
+
group: 'drive',
|
|
288
|
+
summary: 'An app command registered with qa.register (reset, busy, …)',
|
|
289
|
+
positionals: [
|
|
290
|
+
{ name: 'command', help: 'its name', required: true },
|
|
291
|
+
{ name: 'params', help: 'JSON params' },
|
|
292
|
+
],
|
|
293
|
+
needs: 'app',
|
|
294
|
+
run: async (ctx) => ({ json: await (await ctx.device()).command(ctx.args[0]!, ctx.args[1] ? JSON.parse(ctx.args[1]) : undefined) }),
|
|
295
|
+
}),
|
|
296
|
+
define({
|
|
297
|
+
name: 'hello',
|
|
298
|
+
group: 'drive',
|
|
299
|
+
summary: 'What the app said about itself: platform, native, registered commands',
|
|
300
|
+
needs: 'app',
|
|
301
|
+
run: async (ctx) => {
|
|
302
|
+
const { language, locale } = ctx.config;
|
|
303
|
+
const hello = { ...(await ctx.device()).server.hello, ...(language || locale ? { locale: { language, ...locale } } : {}) };
|
|
304
|
+
return { text: JSON.stringify(hello, null, 2), json: hello };
|
|
305
|
+
},
|
|
306
|
+
}),
|
|
307
|
+
define({
|
|
308
|
+
name: 'launch',
|
|
309
|
+
group: 'drive',
|
|
310
|
+
summary: 'Relaunch the app and wait for the agent',
|
|
311
|
+
positionals: [{ name: 'bundleId', help: 'or --app' }],
|
|
312
|
+
flags: { clean: { type: 'boolean', help: "run the app's reset command first, if it is attached" } },
|
|
313
|
+
needs: 'app',
|
|
314
|
+
run: async (ctx) => {
|
|
315
|
+
const device = await ctx.device();
|
|
316
|
+
const appId = ctx.args[0] ?? (ctx.flags.app as string | undefined) ?? ctx.config.app;
|
|
317
|
+
if (!appId) throw new Error('launch needs a bundle id (argument, --app, or app in lynkeus.config.json)');
|
|
318
|
+
if (ctx.flags.clean) await device.command('reset').catch(() => undefined);
|
|
319
|
+
await device.launchApp(appId, { fresh: true });
|
|
320
|
+
await device.idle({ quietMs: 200, timeoutMs: 3000 }).catch(() => undefined);
|
|
321
|
+
const screen = await device.screen();
|
|
322
|
+
return { text: describeScreen(screen), json: screen };
|
|
323
|
+
},
|
|
324
|
+
}),
|
|
325
|
+
define({
|
|
326
|
+
name: 'why',
|
|
327
|
+
mcp: 'core',
|
|
328
|
+
mcpOmit: ['route'],
|
|
329
|
+
mcpDetails: 'Runs by itself after a failed step.',
|
|
330
|
+
group: 'drive',
|
|
331
|
+
summary: 'Why the last thing failed: screen, requests and trace read as rules',
|
|
332
|
+
details: 'Runs by itself after a failed press, wait, type, swipe or nav. Give it what you were looking for to sharpen the answer.',
|
|
333
|
+
positionals: [{ name: 'target', help: `what was expected: Route | ${TARGET_HELP}` }],
|
|
334
|
+
flags: { route: { type: 'string', help: 'the route that was expected', value: 'Route' } },
|
|
335
|
+
needs: 'app',
|
|
336
|
+
run: async (ctx) => {
|
|
337
|
+
const expected = { ...expectedOf(ctx.args[0]), ...(ctx.flags.route ? { route: ctx.flags.route as string } : {}) };
|
|
338
|
+
return (await whyFor(ctx, Object.keys(expected).length ? expected : undefined)) || '(no evidence available)';
|
|
339
|
+
},
|
|
340
|
+
}),
|
|
341
|
+
define({
|
|
342
|
+
name: 'lint',
|
|
343
|
+
group: 'drive',
|
|
344
|
+
summary: 'Unlabeled controls, small targets, untranslated keys, covered or clipped elements',
|
|
345
|
+
flags: {
|
|
346
|
+
strict: { type: 'boolean', help: 'exit 1 on any warning' },
|
|
347
|
+
save: { type: 'string', help: "keep this screen's findings in a baseline file", value: 'file' },
|
|
348
|
+
baseline: { type: 'string', help: 'print only what is new against a baseline file', value: 'file' },
|
|
349
|
+
},
|
|
350
|
+
needs: 'app',
|
|
351
|
+
run: async (ctx) => {
|
|
352
|
+
const screen = await (await ctx.device()).screen();
|
|
353
|
+
const { lint, lintBaseline } = ctx.config;
|
|
354
|
+
let findings = lintScreen(screen, { i18nKeyPatterns: lint?.i18nKeyPatterns?.map((p) => new RegExp(p, 'i')), minTouchTarget: lint?.minTouchTarget });
|
|
355
|
+
const route = screen.route ?? '?';
|
|
356
|
+
const withIds = findings.map((finding) => ({ finding, testId: screen.elements[finding.element]?.testId }));
|
|
357
|
+
const lines: string[] = [];
|
|
358
|
+
const save = ctx.flags.save as string | undefined;
|
|
359
|
+
const baselineFile = (ctx.flags.baseline as string | undefined) ?? lintBaseline;
|
|
360
|
+
if (save) {
|
|
361
|
+
const baseline = fs.existsSync(save) ? readJson<LintBaseline>(save) : {};
|
|
362
|
+
baseline[route] = withIds.map((f) => ({ rule: f.finding.rule, testId: f.testId, message: f.finding.message }));
|
|
363
|
+
writeJson(save, baseline);
|
|
364
|
+
lines.push(`saved ${findings.length} finding(s) for ${route} → ${save}`);
|
|
365
|
+
} else if (baselineFile && fs.existsSync(baselineFile)) {
|
|
366
|
+
findings = newFindings(readJson<LintBaseline>(baselineFile), route, withIds).map((f) => f.finding);
|
|
367
|
+
lines.push(findings.length ? `${findings.length} new against ${baselineFile}:` : `nothing new on ${route} against ${baselineFile}`);
|
|
368
|
+
}
|
|
369
|
+
lines.push(describeFindings(findings, screen.route));
|
|
370
|
+
return { text: lines.join('\n'), json: findings, code: ctx.flags.strict && findings.some((f) => f.severity === 'warn') ? 1 : 0 };
|
|
371
|
+
},
|
|
372
|
+
}),
|
|
373
|
+
define({
|
|
374
|
+
name: 'perf',
|
|
375
|
+
group: 'drive',
|
|
376
|
+
summary: 'Per screen: requests on arrival, time to quiet, errors, stalls, the slowest call',
|
|
377
|
+
flags: {
|
|
378
|
+
since: { type: 'number', help: 'only after this trace seq', value: 'seq' },
|
|
379
|
+
last: { type: 'number', help: 'only the last n trace events', value: 'n' },
|
|
380
|
+
},
|
|
381
|
+
needs: 'app',
|
|
382
|
+
run: async (ctx) => {
|
|
383
|
+
const { events } = await (await ctx.device()).trace(ctx.flags.since as number | undefined);
|
|
384
|
+
const summary = summarize(visitsFrom(ctx.flags.last ? events.slice(-(ctx.flags.last as number)) : events));
|
|
385
|
+
return { text: describePerf(summary), json: summary };
|
|
386
|
+
},
|
|
387
|
+
}),
|
|
388
|
+
define({
|
|
389
|
+
name: 'profile',
|
|
390
|
+
group: 'drive',
|
|
391
|
+
summary: 'What React spent rendering, per route, measured by the Profiler auto() mounts',
|
|
392
|
+
details:
|
|
393
|
+
'`profile start` clears and starts recording, `profile` reports what was recorded since, `profile stop` reports and stops. Recording is off until started, so an app that never asks pays nothing.',
|
|
394
|
+
positionals: [{ name: 'action', help: 'start | stop (omit to report)' }],
|
|
395
|
+
needs: 'app',
|
|
396
|
+
run: async (ctx) => {
|
|
397
|
+
const action = ctx.args[0];
|
|
398
|
+
if (action !== undefined && action !== 'start' && action !== 'stop') return { text: `profile: unknown action ${action} (start | stop)`, code: 2 };
|
|
399
|
+
const result = await (await ctx.device()).command<{
|
|
400
|
+
recording: boolean;
|
|
401
|
+
commits?: number;
|
|
402
|
+
ms?: number;
|
|
403
|
+
routes?: Record<string, { commits: number; ms: number; maxMs: number }>;
|
|
404
|
+
}>('profile', action ? { action } : {});
|
|
405
|
+
if (action === 'start') return { text: 'profile: recording', json: result };
|
|
406
|
+
const routes = Object.entries(result.routes ?? {})
|
|
407
|
+
.sort(([, a], [, b]) => b.ms - a.ms)
|
|
408
|
+
.map(([route, r]) => ` ${route}: ${r.ms} ms in ${r.commits} commits (slowest ${r.maxMs} ms)`);
|
|
409
|
+
const head = `profile: ${result.ms ?? 0} ms in ${result.commits ?? 0} commits${result.recording ? '' : ' (not recording)'}`;
|
|
410
|
+
return { text: [head, ...routes].join('\n'), json: result };
|
|
411
|
+
},
|
|
412
|
+
}),
|
|
413
|
+
];
|