lynkeus 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/lib/cases/coverage.d.ts +31 -0
  2. package/lib/cases/coverage.js +82 -0
  3. package/lib/cases/draft.d.ts +21 -0
  4. package/lib/cases/draft.js +53 -0
  5. package/lib/cases/fixtures.d.ts +11 -0
  6. package/lib/cases/fixtures.js +59 -8
  7. package/lib/cases/frames.d.ts +6 -0
  8. package/lib/cases/frames.js +28 -0
  9. package/lib/cases/nativeLogs.d.ts +14 -0
  10. package/lib/cases/nativeLogs.js +111 -0
  11. package/lib/cases/runDirs.d.ts +13 -0
  12. package/lib/cases/runDirs.js +48 -0
  13. package/lib/cases/runfile.d.ts +5 -1
  14. package/lib/cases/runfile.js +67 -4
  15. package/lib/cases/runner.d.ts +56 -1
  16. package/lib/cases/runner.js +309 -33
  17. package/lib/cases/templates.d.ts +2 -2
  18. package/lib/cases/templates.js +20 -2
  19. package/lib/cases/traces.js +4 -1
  20. package/lib/cli.js +25 -2
  21. package/lib/commands/cases.d.ts +8 -0
  22. package/lib/commands/cases.js +102 -19
  23. package/lib/commands/ci.d.ts +13 -0
  24. package/lib/commands/ci.js +184 -0
  25. package/lib/commands/coverage.d.ts +1 -0
  26. package/lib/commands/coverage.js +151 -0
  27. package/lib/commands/device.js +32 -2
  28. package/lib/commands/drive.js +3 -1
  29. package/lib/commands/fixtures.d.ts +7 -0
  30. package/lib/commands/fixtures.js +94 -0
  31. package/lib/commands/headless.d.ts +11 -0
  32. package/lib/commands/headless.js +69 -3
  33. package/lib/commands/map.d.ts +2 -0
  34. package/lib/commands/map.js +18 -14
  35. package/lib/commands/os.js +43 -10
  36. package/lib/commands/report.d.ts +4 -0
  37. package/lib/commands/report.js +154 -18
  38. package/lib/config.d.ts +25 -0
  39. package/lib/device/agentVersion.d.ts +7 -0
  40. package/lib/device/agentVersion.js +14 -0
  41. package/lib/device/devices.js +6 -0
  42. package/lib/knowledge/analyticsTraffic.d.ts +10 -0
  43. package/lib/knowledge/analyticsTraffic.js +109 -0
  44. package/lib/knowledge/errors.js +6 -0
  45. package/lib/knowledge/events.d.ts +2 -0
  46. package/lib/knowledge/why.d.ts +2 -0
  47. package/lib/knowledge/why.js +1 -1
  48. package/lib/mcp/server.js +16 -4
  49. package/lib/runtime.d.ts +9 -1
  50. package/lib/runtime.js +25 -3
  51. package/package.json +7 -4
  52. package/src/cases/coverage.ts +99 -0
  53. package/src/cases/draft.ts +75 -0
  54. package/src/cases/fixtures.ts +57 -8
  55. package/src/cases/frames.ts +30 -0
  56. package/src/cases/nativeLogs.ts +112 -0
  57. package/src/cases/runDirs.ts +48 -0
  58. package/src/cases/runfile.ts +64 -4
  59. package/src/cases/runner.ts +359 -37
  60. package/src/cases/templates.ts +20 -2
  61. package/src/cases/traces.ts +3 -1
  62. package/src/cli.ts +25 -2
  63. package/src/commands/cases.ts +111 -18
  64. package/src/commands/ci.ts +189 -0
  65. package/src/commands/coverage.ts +147 -0
  66. package/src/commands/device.ts +27 -2
  67. package/src/commands/drive.ts +3 -1
  68. package/src/commands/fixtures.ts +102 -0
  69. package/src/commands/headless.ts +66 -3
  70. package/src/commands/map.ts +20 -15
  71. package/src/commands/os.ts +38 -10
  72. package/src/commands/report.ts +160 -16
  73. package/src/config.ts +15 -0
  74. package/src/device/agentVersion.ts +17 -0
  75. package/src/device/devices.ts +5 -0
  76. package/src/knowledge/analyticsTraffic.ts +142 -0
  77. package/src/knowledge/errors.ts +6 -0
  78. package/src/knowledge/events.ts +2 -1
  79. package/src/knowledge/why.ts +3 -1
  80. package/src/mcp/server.ts +16 -4
  81. package/src/runtime.ts +24 -3
package/src/config.ts CHANGED
@@ -51,6 +51,8 @@ export const loadGraph = (root?: string): ScreenGraph | null => {
51
51
  */
52
52
  export type LynkeusConfig = {
53
53
  cases?: CasesConfig;
54
+ /** What `lynkeus ci` brings up around a suite: the project's backend and the app's hosts. */
55
+ ci?: CiConfig;
54
56
  /** The headless entry (default `qa/headless.tsx`). */
55
57
  entry?: string;
56
58
  /** The environment variable the app reads its API base from; the headless entry refuses one that is not local. */
@@ -66,6 +68,8 @@ export type LynkeusConfig = {
66
68
  device?: string;
67
69
  /** Bundle id or application id. */
68
70
  app?: string;
71
+ /** What a pushed run's local folder becomes: kept (the default), or deleted once the service has every file. */
72
+ report?: { afterPush?: 'keep' | 'delete' };
69
73
  /**
70
74
  * The language this app speaks, passed on and never interpreted: `os
71
75
  * language` defaults to it, the headless entry mocks this locale, and an MCP
@@ -126,6 +130,17 @@ export const loadConfig = (root?: string): LynkeusConfig => {
126
130
  return { ...own, ...extra };
127
131
  };
128
132
 
133
+ export type CiConfig = {
134
+ /** Shell commands that bring the project's backend up and down, and a URL that answers once it can serve. */
135
+ backend?: { up: string; down?: string; ready?: string; timeoutMs?: number };
136
+ /** How many hosts of the app, and the command that starts one (`{port}` is where it dials). Default: headless. */
137
+ hosts?: { count?: number; command?: string; timeoutMs?: number };
138
+ /** Upload the run (needs LYNKEUS_REPORTS_KEY). */
139
+ push?: boolean;
140
+ /** Extra `case run` flags, as on the command line. */
141
+ run?: string[];
142
+ };
143
+
129
144
  export const portFromEnv = (config: LynkeusConfig = {}): number | undefined => (process.env.LYNKEUS_PORT ? Number(process.env.LYNKEUS_PORT) : config.port);
130
145
 
131
146
  /** A list of regular expressions from the configuration, as one that matches any of them. */
@@ -13,3 +13,20 @@ export const agentMismatch = (hello: Pick<HelloEvent, 'agentVersion'>, cli = CLI
13
13
  const agent = hello.agentVersion ? `lynkeus-agent ${hello.agentVersion}` : 'a lynkeus-agent too old to say its version';
14
14
  return `⚠️ The app runs ${agent}, this CLI is lynkeus ${cli}: they can misread each other without an error. Install lynkeus-agent@${cli} in the app and rebuild it (or run the lynkeus CLI of the agent's version).`;
15
15
  };
16
+
17
+ /** Who said hello: the app and where it runs, enough to tell two apps dialing the same port apart. */
18
+ export const helloIdentity = (hello: Pick<HelloEvent, 'platform' | 'native' | 'app'>): string => {
19
+ const app = hello.app ?? {};
20
+ const name = typeof app.name === 'string' ? app.name : 'an app';
21
+ const runtime = typeof app.runtime === 'string' ? app.runtime : hello.native ? 'native' : 'hosted';
22
+ return `${name} (${hello.platform}, ${runtime})`;
23
+ };
24
+
25
+ /**
26
+ * The driver keeps the app that dialed in last. Two apps or hosts dialing the same port take turns
27
+ * without an error, each step going to whichever came last: say so the first time a different one comes in.
28
+ */
29
+ export const appSwitched = (before: string | undefined, now: string, port: number): string | undefined =>
30
+ before && before !== now
31
+ ? `⚠️ Another app attached on port ${port}: ${before} gave way to ${now}. Two apps or hosts are dialing this port and steps go to whichever dialed last; stop one, or give each its own LYNKEUS_PORT.`
32
+ : undefined;
@@ -63,6 +63,11 @@ export const pickAgentDevice = async (wanted?: string): Promise<DeviceInfo> => {
63
63
  const devices = await bootedSimulators();
64
64
  const id = wanted ?? process.env.LYNKEUS_DEVICE;
65
65
  const device = id ? devices.find((d) => d.id === id || d.name.includes(id)) : devices[0];
66
+ if (!device && id) {
67
+ // An adb serial names an Android device; which platform the app is on is only known once it dials in.
68
+ const android = (await androidDevices()).find((d) => d.id === id);
69
+ if (android) return android;
70
+ }
66
71
  if (!device) {
67
72
  // A headless host dials the driver itself: nothing to boot or attach to.
68
73
  if (!id && devices.length === 0) return HEADLESS_HOST;
@@ -0,0 +1,142 @@
1
+ // The analytics, crash and attribution services an app talks to over HTTP, known by where it sends: a JS
2
+ // SDK (Segment, PostHog, Amplitude's browser SDK) never touches a native module, so its events show up only
3
+ // as requests. Each request becomes the events its body carries, or one event named after the call.
4
+ import type { RequestRecord } from 'lynkeus-protocol';
5
+
6
+ export type TrafficEvent = { provider: string; name: string; props?: Record<string, unknown> };
7
+
8
+ type Plain = Record<string, unknown>;
9
+ // `provider` is the service the request went to: a reader of one shape serves every service that copies it.
10
+ type Reader = (body: unknown, path: string, provider: string) => TrafficEvent[] | undefined;
11
+
12
+ const isPlain = (v: unknown): v is Plain => !!v && typeof v === 'object' && !Array.isArray(v);
13
+ const plain = (v: unknown): Plain | undefined => (isPlain(v) ? v : undefined);
14
+ const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : v === undefined ? [] : [v]);
15
+
16
+ const named = (provider: string, items: unknown[], name: (item: Plain) => unknown, props: (item: Plain) => unknown): TrafficEvent[] =>
17
+ items.filter(isPlain).flatMap((item) => {
18
+ const n = name(item);
19
+ if (typeof n !== 'string' || !n) return [];
20
+ const p = plain(props(item));
21
+ return [{ provider, name: n, ...(p ? { props: p } : {}) }];
22
+ });
23
+
24
+ const segment: Reader = (body, _path, provider) => {
25
+ const b = plain(body);
26
+ return named(
27
+ provider,
28
+ list(b?.batch ?? b),
29
+ (i) => i.event ?? (i.type === 'screen' || i.type === 'page' ? `${i.type}: ${i.name}` : i.type),
30
+ (i) => i.properties ?? i.traits,
31
+ );
32
+ };
33
+ const amplitude: Reader = (body) =>
34
+ named(
35
+ 'amplitude',
36
+ list(plain(body)?.events),
37
+ (i) => i.event_type,
38
+ (i) => i.event_properties,
39
+ );
40
+ const posthog: Reader = (body) => {
41
+ const b = plain(body);
42
+ return named(
43
+ 'posthog',
44
+ list(b?.batch ?? (Array.isArray(body) ? body : b)),
45
+ (i) => i.event,
46
+ (i) => i.properties,
47
+ );
48
+ };
49
+ const mixpanel: Reader = (body) =>
50
+ named(
51
+ 'mixpanel',
52
+ list(body),
53
+ (i) => i.event ?? (i.$set ? '$set' : undefined),
54
+ (i) => i.properties ?? i.$set,
55
+ );
56
+
57
+ /** Host pattern → the service, and how to read its body when there is a known shape. */
58
+ const SERVICES: { host: RegExp; provider: string; read?: Reader }[] = [
59
+ { host: /(^|\.)segment\.(io|com)$/, provider: 'segment', read: segment },
60
+ { host: /(^|\.)amplitude\.com$/, provider: 'amplitude', read: amplitude },
61
+ { host: /(^|\.)posthog\.com$/, provider: 'posthog', read: posthog },
62
+ { host: /(^|\.)mixpanel\.com$/, provider: 'mixpanel', read: mixpanel },
63
+ { host: /(^|\.)sentry\.io$|^sentry\./, provider: 'sentry' },
64
+ { host: /(^|\.)(datadoghq\.(com|eu)|ddog-gov\.com)$/, provider: 'datadog' },
65
+ { host: /(^|\.)app-measurement\.com$|^firebaselogging/, provider: 'firebase' },
66
+ { host: /(^|\.)appsflyer(sdk)?\.com$/, provider: 'appsflyer' },
67
+ { host: /(^|\.)branch\.io$/, provider: 'branch' },
68
+ { host: /(^|\.)braze\.(com|eu)$/, provider: 'braze' },
69
+ { host: /(^|\.)onesignal\.com$/, provider: 'onesignal' },
70
+ { host: /(^|\.)intercom\.(io|com)$/, provider: 'intercom' },
71
+ { host: /(^|\.)bugsnag\.com$/, provider: 'bugsnag' },
72
+ { host: /(^|\.)adjust\.(com|net|world)$/, provider: 'adjust' },
73
+ { host: /(^|\.)clevertap-prod\.com$|(^|\.)wzrkt\.com$/, provider: 'clevertap' },
74
+ { host: /(^|\.)heapanalytics\.com$/, provider: 'heap' },
75
+ { host: /(^|\.)statsig(api)?\.(com|net)$/, provider: 'statsig' },
76
+ { host: /(^|\.)launchdarkly\.com$/, provider: 'launchdarkly' },
77
+ // Segment-compatible: the same batch shape.
78
+ { host: /(^|\.)rudderstack\.com$|(^|\.)rudderlabs\.com$/, provider: 'rudderstack', read: segment },
79
+ { host: /^(track|cdp|track-eu|cdp-eu)\.customer\.io$/, provider: 'customerio', read: segment },
80
+ { host: /(^|\.)google-analytics\.com$|(^|\.)analytics\.google\.com$/, provider: 'googleanalytics' },
81
+ { host: /(^|\.)googletagmanager\.com$/, provider: 'googletagmanager' },
82
+ { host: /^graph\.facebook\.com$/, provider: 'meta' },
83
+ { host: /(^|\.)tiktok(v)?\.com$|(^|\.)tiktokapis\.com$/, provider: 'tiktok' },
84
+ { host: /(^|\.)iterable\.com$/, provider: 'iterable' },
85
+ { host: /(^|\.)moengage\.com$/, provider: 'moengage' },
86
+ { host: /(^|\.)kochava\.com$/, provider: 'kochava' },
87
+ { host: /(^|\.)singular\.net$/, provider: 'singular' },
88
+ { host: /(^|\.)emb-api\.com$|(^|\.)embrace\.io$/, provider: 'embrace' },
89
+ { host: /(^|\.)instabug\.com$|(^|\.)luciq\.ai$/, provider: 'instabug' },
90
+ { host: /(^|\.)smartlook\.(cloud|com)$/, provider: 'smartlook' },
91
+ { host: /(^|\.)urbanairship\.com$|(^|\.)airship\.(com|eu)$|(^|\.)asnapieu\.com$/, provider: 'airship' },
92
+ { host: /(^|\.)snplow\.net$|(^|\.)snowplowanalytics\.com$/, provider: 'snowplow' },
93
+ { host: /(^|\.)fullstory\.com$/, provider: 'fullstory' },
94
+ { host: /(^|\.)mparticle\.com$/, provider: 'mparticle' },
95
+ { host: /(^|\.)pendo\.io$/, provider: 'pendo' },
96
+ { host: /(^|\.)lr-ingest\.(io|com)$|(^|\.)logrocket\.(io|com)$/, provider: 'logrocket' },
97
+ { host: /(^|\.)newrelic\.com$|(^|\.)nr-data\.net$/, provider: 'newrelic' },
98
+ { host: /(^|\.)hotjar\.(com|io)$/, provider: 'hotjar' },
99
+ { host: /(^|\.)count\.ly$/, provider: 'countly' },
100
+ ];
101
+
102
+ const parse = (text: string | undefined): unknown => {
103
+ if (!text) return undefined;
104
+ try {
105
+ return JSON.parse(text);
106
+ } catch {
107
+ // Mixpanel's classic endpoint sends `data=<base64 JSON>` as a form.
108
+ const data = /(?:^|&)data=([^&]+)/.exec(text)?.[1];
109
+ if (!data) return undefined;
110
+ try {
111
+ const raw = decodeURIComponent(data);
112
+ return JSON.parse(raw.startsWith('[') || raw.startsWith('{') ? raw : Buffer.from(raw, 'base64').toString('utf8'));
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+ };
118
+
119
+ /** The service a request went to, when it is one of the known ones. */
120
+ export const serviceOf = (url: string): string | undefined => {
121
+ try {
122
+ const host = new URL(url).hostname.toLowerCase();
123
+ return SERVICES.find((s) => s.host.test(host))?.provider;
124
+ } catch {
125
+ return undefined;
126
+ }
127
+ };
128
+
129
+ /** What a step's requests told analytics services: each body's events, or one per call to a service it cannot read. */
130
+ export const trafficEvents = (requests: RequestRecord[]): TrafficEvent[] =>
131
+ requests.flatMap((r) => {
132
+ let url: URL;
133
+ try {
134
+ url = new URL(r.url);
135
+ } catch {
136
+ return [];
137
+ }
138
+ const service = SERVICES.find((s) => s.host.test(url.hostname.toLowerCase()));
139
+ if (!service) return [];
140
+ const events = service.read?.(parse(r.request?.body), url.pathname, service.provider);
141
+ return events?.length ? events : [{ provider: service.provider, name: `${r.method} ${url.pathname}` }];
142
+ });
@@ -47,6 +47,12 @@ const RULES: Rule[] = [
47
47
  cause: (m) => `nothing is listening on ${m[1]}:${m[2]}`,
48
48
  fix: () => `start the backend (or the lynkeus driver, if this is the agent's socket) and check the port the entry dials.`,
49
49
  },
50
+ {
51
+ // Babel loads the project's config with require(); a plugin shipped as ESM only loads that way from Node 22.12.
52
+ re: /ERR_REQUIRE_ESM(?:[^\n]*?require\(\) of ES Module (\S+))?|require\(\) of ES Module (\S+)/,
53
+ cause: (m) => `Node ${process.versions.node} cannot require() an ES module${m[1] || m[2] ? ` (${m[1] || m[2]})` : ''}, which the babel config loads`,
54
+ fix: () => `run with Node 22.12 or newer (lynkeus-headless needs >=22), where require() loads ES modules.`,
55
+ },
50
56
  {
51
57
  re: /Test environment jest-environment-\w+ cannot be found|Unexpected token 'export'|SyntaxError: Cannot use import statement/,
52
58
  cause: () => `jest is not transforming a dependency (ESM in node_modules)`,
@@ -4,7 +4,8 @@ import path from 'node:path';
4
4
  import { readJsonl } from '../files.js';
5
5
 
6
6
  export type AppEvent = { name: string; props?: Record<string, unknown> };
7
- export type RunEvent = AppEvent & { step: number; route?: string };
7
+ /** `provider`: the service the app's own list feeds, when the project says (`cases.appEvents`). */
8
+ export type RunEvent = AppEvent & { step: number; route?: string; provider?: string };
8
9
 
9
10
  type Drainable = { command<T = unknown>(name: string, params?: unknown): Promise<T> };
10
11
 
@@ -14,6 +14,8 @@ export type Evidence = {
14
14
  trace?: TraceEvent[];
15
15
  /** What the failed command was looking for. */
16
16
  target?: { testId?: string; text?: string; route?: string };
17
+ /** Whether the app made any request before `requests` begins: `requests` may be only those of the failed command. */
18
+ fetchedBefore?: boolean;
17
19
  };
18
20
 
19
21
  export type Finding = { rule: string; message: string };
@@ -159,7 +161,7 @@ export const explain = (e: Evidence): Finding[] => {
159
161
  message: `${e.inFlight} request(s) still in flight when the command gave up: the screen had not finished loading. A longer timeout, or wait for the request it needs.`,
160
162
  });
161
163
 
162
- if (done.length === 0 && e.inFlight === 0 && e.target?.route)
164
+ if (done.length === 0 && e.inFlight === 0 && e.target?.route && !e.fetchedBefore)
163
165
  out.push({
164
166
  rule: 'no-requests',
165
167
  message: 'no request left the app while this ran: nothing was fetched. Is the user logged in, and is the app talking to the backend it should?',
package/src/mcp/server.ts CHANGED
@@ -68,11 +68,13 @@ In a project you do not know, call status first: it lists what is set up and, fo
68
68
 
69
69
  To set lynkeus up: call init (it installs lynkeus-agent, mounts it from the app's entry and makes the project's token; nobody sets a variable). The agent has native code, so the app must then be built again (npx react-native run-ios or run-android; npx expo run:ios or run:android, never Expo Go) with Metro restarted with --reset-cache. Do that with your shell if you have one, or ask the person to. Then status until an app is attached.
70
70
 
71
- To write a test: call case_init once if the project has no cases, and read the README.md it writes for the format. Go to the screen with press, type and wait, calling screen to see what is there; prefer testIDs (#id) over text. Write the case as a markdown file in the cases folder, then call case_run with that file. A failed step comes back with the screen it failed on: fix the case or report the app's bug, do not weaken the check.
71
+ To write a test: call case_init once if the project has no cases, and read the README.md it writes for the format. Go to the screen with press, type and wait, calling screen to see what is there (press with settle: true answers with the screen once the app has settled; a screen read right after a press can still be the one it left); prefer testIDs (#id) over text. Write the case as a markdown file in the cases folder, then call case_run with that file. A failed step comes back with the screen it failed on: fix the case or report the app's bug, do not weaken the check.
72
72
 
73
73
  To test without a simulator: call headless_init (it installs what the Node host needs and writes its entry; an Expo Router app works too), then headless_doctor for the native modules still to mock, then case_run with host. The backend the app talks to must be local: set apiUrl in lynkeus.config.json.
74
74
 
75
- To share a run: case_run with out, screenshots and push uploads it and prints the report's address. That needs LYNKEUS_REPORTS_KEY in this server's environment.`;
75
+ To share a run: case_run with out, screenshots and push uploads it and prints the report's address. That needs LYNKEUS_REPORTS_KEY in this server's environment.
76
+
77
+ For a run with everything (frames, film, requests, analytics, backend): case_run with out, screenshots, video (film needs fastsim), traces and push. When the run ends it warns about evidence it could not keep: pass that on.`;
76
78
 
77
79
  export const serve = async (session: Session, options: { full?: boolean } = {}) => {
78
80
  const { version } = createRequire(import.meta.url)('../../package.json') as { version: string };
@@ -115,9 +117,19 @@ export const serve = async (session: Session, options: { full?: boolean } = {})
115
117
  if (request.params.name === 'app_locale' && locale) return text(locale);
116
118
  const c = byName.get(request.params.name);
117
119
  if (!c) return { ...text(`✗ no tool ${request.params.name}`), isError: true };
118
- const r = await execute(argvFor(c, (request.params.arguments ?? {}) as Record<string, unknown>), session.ctx);
120
+ const input = (request.params.arguments ?? {}) as Record<string, unknown>;
121
+ // An argument the tool does not take is dropped by argvFor; a run would then go on without it (every case
122
+ // instead of the one named), so it is refused, with what the tool does take.
123
+ const known = Object.keys(schemaFor(c).properties);
124
+ const unknown = Object.keys(input).filter((key) => !known.includes(key));
125
+ if (unknown.length)
126
+ return { ...text(`✗ ${request.params.name} takes no ${unknown.join(', ')}; it takes ${known.join(', ') || 'no arguments'}`), isError: true };
127
+ const r = await execute(argvFor(c, input), session.ctx);
119
128
  if (r.error) return { ...text(`✗ ${r.error}${r.why ? `\n\nwhy:\n${r.why}` : ''}`), isError: true };
120
- return text(r.text || (r.json !== undefined ? JSON.stringify(r.json, null, 2) : 'ok'));
129
+ const body = text(r.text || (r.json !== undefined ? JSON.stringify(r.json, null, 2) : 'ok'));
130
+ // A command that ended with a non-zero code (a failed case, a server that does not keep the contract) is
131
+ // reported as such, with everything it said, so a model does not read it as success.
132
+ return r.code === 0 ? body : { ...body, isError: true };
121
133
  });
122
134
  // The client going away ends the server: the session holds the driver's port open, and a server left behind
123
135
  // keeps the next one (or a case run) from binding it.
package/src/runtime.ts CHANGED
@@ -11,18 +11,38 @@ import { type Expected, expectedOf } from './targets.js';
11
11
  export type Outcome = { text?: string; json?: unknown; code: number; error?: string; why?: string };
12
12
 
13
13
  /** The screen, requests and trace after a failure, read as rules; empty when there is nothing to read. */
14
- export const whyFor = async (base: Base, expected?: Expected): Promise<string> => {
14
+ export const whyFor = async (base: Base, expected?: Expected, startedAt?: number): Promise<string> => {
15
15
  try {
16
16
  const device = await base.device();
17
17
  const [screen, requests, trace] = await Promise.all([device.screen(), device.requests(), device.trace()]);
18
+ const during = duringCommand(requests.requests, startedAt);
18
19
  return describeWhy(
19
- explain({ screen, requests: requests.requests.slice(-40), inFlight: requests.inFlight, trace: trace.events.slice(-40), target: expected }),
20
+ explain({
21
+ screen,
22
+ requests: during.slice(-40),
23
+ inFlight: requests.inFlight,
24
+ trace: trace.events.slice(-40),
25
+ target: expected,
26
+ fetchedBefore: during.length < requests.requests.length,
27
+ }),
20
28
  );
21
29
  } catch {
22
30
  return '';
23
31
  }
24
32
  };
25
33
 
34
+ /**
35
+ * The requests made while the failed command ran, and a little before: a 403 from minutes ago is not why a wait
36
+ * failed now. Only when the app's clock reads like this machine's (a simulator, an emulator); a host on a clock of
37
+ * its own keeps them all.
38
+ */
39
+ export const duringCommand = <R extends { startedAt: number }>(requests: R[], startedAt: number | undefined, now = Date.now()): R[] => {
40
+ if (startedAt === undefined || requests.length === 0) return requests;
41
+ const latest = Math.max(...requests.map((r) => r.startedAt));
42
+ if (Math.abs(latest - now) > 10 * 60_000) return requests;
43
+ return requests.filter((r) => r.startedAt >= startedAt - 3000);
44
+ };
45
+
26
46
  const TARGET_ARG: Record<string, number> = { press: 0, see: 0, wait: 0, swipe: 1, nav: 0, type: 1 };
27
47
 
28
48
  export const execute = async (argv: string[], base: Base): Promise<Outcome> => {
@@ -37,6 +57,7 @@ export const execute = async (argv: string[], base: Base): Promise<Outcome> => {
37
57
  throw error;
38
58
  }
39
59
  if (parsed.flags.help) return { code: 0, text: helpFor(command) };
60
+ const startedAt = Date.now();
40
61
  try {
41
62
  const result = await command.run({ ...base, ...parsed });
42
63
  if (result === undefined) return { code: 0 };
@@ -46,7 +67,7 @@ export const execute = async (argv: string[], base: Base): Promise<Outcome> => {
46
67
  const out: Outcome = { code: 1, error: error instanceof Error ? error.message : String(error) };
47
68
  const at = TARGET_ARG[command.name];
48
69
  if (at !== undefined) {
49
- const why = await whyFor(base, expectedOf(parsed.args[at]));
70
+ const why = await whyFor(base, expectedOf(parsed.args[at]), startedAt);
50
71
  if (why) out.why = why;
51
72
  }
52
73
  return out;