lynkeus 0.2.0 → 0.4.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 (134) hide show
  1. package/lib/cases/ahead.d.ts +31 -0
  2. package/lib/cases/ahead.js +90 -0
  3. package/lib/cases/fixtures.d.ts +12 -0
  4. package/lib/cases/fixtures.js +32 -0
  5. package/lib/cases/flakes.d.ts +28 -0
  6. package/lib/cases/flakes.js +61 -0
  7. package/lib/cases/format.d.ts +5 -0
  8. package/lib/cases/format.js +30 -4
  9. package/lib/cases/portClaim.d.ts +10 -0
  10. package/lib/cases/portClaim.js +55 -0
  11. package/lib/cases/runfile.d.ts +24 -0
  12. package/lib/cases/runfile.js +108 -0
  13. package/lib/cases/runner.d.ts +82 -3
  14. package/lib/cases/runner.js +394 -40
  15. package/lib/cases/templates.d.ts +2 -2
  16. package/lib/cases/templates.js +14 -4
  17. package/lib/cases/traces.d.ts +58 -0
  18. package/lib/cases/traces.js +250 -0
  19. package/lib/cli.js +28 -7
  20. package/lib/commands/cases.js +411 -42
  21. package/lib/commands/check.d.ts +6 -0
  22. package/lib/commands/check.js +8 -3
  23. package/lib/commands/doctor.js +8 -2
  24. package/lib/commands/drive.js +32 -8
  25. package/lib/commands/headless.js +132 -8
  26. package/lib/commands/init.d.ts +29 -0
  27. package/lib/commands/init.js +295 -33
  28. package/lib/commands/report.d.ts +97 -0
  29. package/lib/commands/report.js +228 -0
  30. package/lib/config.d.ts +26 -1
  31. package/lib/config.js +30 -0
  32. package/lib/device/agentVersion.d.ts +7 -0
  33. package/lib/device/agentVersion.js +12 -0
  34. package/lib/device/devices.d.ts +5 -0
  35. package/lib/device/devices.js +27 -0
  36. package/lib/device/driver.js +2 -2
  37. package/lib/flow/runner.js +33 -1
  38. package/lib/flow/types.d.ts +2 -1
  39. package/lib/knowledge/contrast.d.ts +45 -0
  40. package/lib/knowledge/contrast.js +114 -0
  41. package/lib/knowledge/errors.js +1 -1
  42. package/lib/knowledge/lint.d.ts +2 -0
  43. package/lib/knowledge/lint.js +13 -3
  44. package/lib/mcp/server.js +31 -2
  45. package/lib/registry.d.ts +5 -0
  46. package/lib/targets.d.ts +10 -1
  47. package/lib/targets.js +16 -3
  48. package/package.json +10 -6
  49. package/src/cases/ahead.ts +111 -0
  50. package/src/cases/fixtures.ts +37 -0
  51. package/src/cases/flakes.ts +67 -0
  52. package/src/cases/format.ts +31 -6
  53. package/src/cases/portClaim.ts +52 -0
  54. package/src/cases/runfile.ts +119 -0
  55. package/src/cases/runner.ts +454 -46
  56. package/src/cases/templates.ts +14 -4
  57. package/src/cases/traces.ts +298 -0
  58. package/src/cli.ts +29 -7
  59. package/src/commands/cases.ts +428 -41
  60. package/src/commands/check.ts +11 -4
  61. package/src/commands/doctor.ts +9 -2
  62. package/src/commands/drive.ts +31 -8
  63. package/src/commands/headless.ts +143 -8
  64. package/src/commands/init.ts +338 -30
  65. package/src/commands/report.ts +272 -0
  66. package/src/config.ts +39 -2
  67. package/src/device/agentVersion.ts +15 -0
  68. package/src/device/devices.ts +28 -0
  69. package/src/device/driver.ts +2 -2
  70. package/src/flow/runner.ts +35 -1
  71. package/src/flow/types.ts +2 -2
  72. package/src/knowledge/contrast.ts +135 -0
  73. package/src/knowledge/errors.ts +1 -1
  74. package/src/knowledge/lint.ts +15 -4
  75. package/src/mcp/server.ts +31 -2
  76. package/src/registry.ts +2 -0
  77. package/src/targets.ts +17 -3
  78. package/lib/__tests__/args.test.d.ts +0 -1
  79. package/lib/__tests__/args.test.js +0 -69
  80. package/lib/__tests__/check.test.d.ts +0 -1
  81. package/lib/__tests__/check.test.js +0 -60
  82. package/lib/__tests__/init.test.d.ts +0 -1
  83. package/lib/__tests__/init.test.js +0 -42
  84. package/lib/__tests__/record.test.d.ts +0 -1
  85. package/lib/__tests__/record.test.js +0 -44
  86. package/lib/__tests__/snapshot.test.d.ts +0 -1
  87. package/lib/__tests__/snapshot.test.js +0 -47
  88. package/lib/__tests__/targets.test.d.ts +0 -1
  89. package/lib/__tests__/targets.test.js +0 -127
  90. package/lib/cases/__tests__/format.test.d.ts +0 -1
  91. package/lib/cases/__tests__/format.test.js +0 -61
  92. package/lib/cases/__tests__/runner.test.d.ts +0 -1
  93. package/lib/cases/__tests__/runner.test.js +0 -188
  94. package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
  95. package/lib/knowledge/__tests__/edges.test.js +0 -25
  96. package/lib/knowledge/__tests__/events.test.d.ts +0 -1
  97. package/lib/knowledge/__tests__/events.test.js +0 -39
  98. package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
  99. package/lib/knowledge/__tests__/extract.test.js +0 -69
  100. package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
  101. package/lib/knowledge/__tests__/frontier.test.js +0 -57
  102. package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
  103. package/lib/knowledge/__tests__/funnels.test.js +0 -40
  104. package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
  105. package/lib/knowledge/__tests__/lint.test.js +0 -53
  106. package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
  107. package/lib/knowledge/__tests__/perf.test.js +0 -34
  108. package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
  109. package/lib/knowledge/__tests__/plan.test.js +0 -35
  110. package/lib/knowledge/__tests__/query.test.d.ts +0 -1
  111. package/lib/knowledge/__tests__/query.test.js +0 -55
  112. package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
  113. package/lib/knowledge/__tests__/screens.test.js +0 -30
  114. package/lib/knowledge/__tests__/why.test.d.ts +0 -1
  115. package/lib/knowledge/__tests__/why.test.js +0 -28
  116. package/src/__tests__/args.test.ts +0 -82
  117. package/src/__tests__/check.test.ts +0 -71
  118. package/src/__tests__/init.test.ts +0 -52
  119. package/src/__tests__/record.test.ts +0 -49
  120. package/src/__tests__/snapshot.test.ts +0 -51
  121. package/src/__tests__/targets.test.ts +0 -132
  122. package/src/cases/__tests__/format.test.ts +0 -76
  123. package/src/cases/__tests__/runner.test.ts +0 -230
  124. package/src/knowledge/__tests__/edges.test.ts +0 -34
  125. package/src/knowledge/__tests__/events.test.ts +0 -45
  126. package/src/knowledge/__tests__/extract.test.ts +0 -88
  127. package/src/knowledge/__tests__/frontier.test.ts +0 -77
  128. package/src/knowledge/__tests__/funnels.test.ts +0 -58
  129. package/src/knowledge/__tests__/lint.test.ts +0 -66
  130. package/src/knowledge/__tests__/perf.test.ts +0 -41
  131. package/src/knowledge/__tests__/plan.test.ts +0 -52
  132. package/src/knowledge/__tests__/query.test.ts +0 -74
  133. package/src/knowledge/__tests__/screens.test.ts +0 -37
  134. package/src/knowledge/__tests__/why.test.ts +0 -40
@@ -4,8 +4,8 @@
4
4
  * project starts from something that runs instead of from a blank folder.
5
5
  */
6
6
  export declare const EXAMPLE_CASE = "---\nid: example\ntitle: \"A user who was given points sees them on the first screen\"\ndescription: >\n What this proves and why it matters, in a sentence or two. A case reads top\n to bottom: what has to exist, what the person does, what must be true after.\n---\nsetup:\n # Anything that is not an app step is a fixture: fixtures.mjs answers it.\n - create_user: { as: u, name: \"Ana\" }\n\nsteps:\n - app.see: { text: \"Hello\" }\n # `user: u` points at what `create_user` answered (cases.refs in lynkeus.config.json).\n - give_points: { user: u, points: 25 }\n - app.see: { text: \"25 points\", timeoutMs: 10000 }\n - assert: { user: u, path: points, equals: 25 }\n\nexpected: |\n The points on screen and the points the backend holds are both 25.\n";
7
- export declare const FORMAT = "# Cases\n\nA case is a markdown file: frontmatter that names it, then `setup`, `steps`\nand `expected`. `lynkeus case run cases/example.md` runs one; without files\nit runs every case in this folder. A step that fails stops the case, and the\nreport carries the screen, the requests and the reason.\n\n## Steps that drive the app\n\n| Step | Value | |\n|---|---|---|\n| `app.press` | target, or `{ target, within?, nth?, optional?, hold? }` | Waits for the control to be there and enabled. `optional` presses only if it is on screen. |\n| `app.type` | `{ text, target?, submit?, clear? }` | `target` is tapped first; `submit` ends with the return key. |\n| `app.swipe` | `{ direction, target?, distance? }` or `{ from, to }` | |\n| `app.wait` | route, target, ms, `{ target, scroll? }` or `{ request, status? }` | `scroll` swipes until the target is in the window. A `request` is `METHOD /path`. |\n| `app.see` | target, or `{ testId?, text?, route?, request?, event?, layer?, absent?, timeoutMs? }` | An assertion on what the app shows or did. Everything named must hold. |\n| `app.nav` | route, or `{ route, params }` | A teleport, for getting to where the case starts. |\n| `app.back` | | |\n| `app.requests` | `mark` | From here on, `request` only counts what the app asks for. |\n| `app.call` | `{ command, params?, expect?, as? }` | A command the app registered with `qa.register`. |\n| `app.run` | `{ flow, vars? }` | A lynkeus flow, from the flows folder. |\n| `app.events` / `app.event` | `clear` / `{ name, props?, absent? }` | What the app told its analytics. |\n| `app.<command>` | its arguments by name | Any other lynkeus command: `app.mock`, `app.network`, `app.appstate`, `app.deeplink`\u2026 |\n\nA target is `#testId`, `\"text\"`, an index or `x,y`.\n\nA case that runs over several inputs declares `matrix: { locale: [en, fr] }`\nin its frontmatter and runs once per combination; `{{matrix.locale}}` is that\nrun's value.\n\nOne case runs on a host and on a device. What only one of them needs \u2014 closing a\nnative screen the host never shows \u2014 is kept to it:\n\n```yaml\n - on: device # or headless, ios, android\n do:\n - app.press: \"364,84\"\n```\n\n## Steps that prepare and read the backend\n\nAny other step name is a **fixture**: lynkeus sends it to the command in\n`cases.fixtures` (lynkeus.config.json) as one JSON line and reads one back.\n\n```\n\u2192 {\"command\": \"create_user\", \"params\": {\"name\": \"Ana\"}, \"env\": {\"NAME\": \"Ana\"}}\n\u2190 {\"ok\": true, \"user_id\": \"u_1\", \"name\": \"Ana\"}\n```\n\n`as: u` keeps the answer; `{{u.user_id}}` reads it later. `assert` calls\nthe `inspect` fixture and checks one path of its answer with `equals`,\n`includes`, `gte`, `lte`, `near`, `matches`, `present` or `absent`.\n`exec: { run: \"\u2026\", as? }` runs a shell command and keeps its JSON output.\n\n## Steps of your own\n\n`cases.macros` names a step and the steps it stands for. Inside,\n`{{param.x}}` is what the case passed, and a parameter that names an alias\nis that alias.\n\n```json\n\"macros\": {\n \"app.login\": [\n { \"app.call\": { \"command\": \"reset\" } },\n { \"app.deeplink\": \"myapp://dev-login?user={{param.as.user_id}}\" },\n { \"when\": \"wait\", \"do\": { \"app.wait\": \"{{param.wait}}\" } }\n ]\n}\n```\n\n`when` runs a step only if the case passed that parameter (`\"when\": \"wait\"`)\nor passed that value (`\"when\": \"method=phone\"`); `unless` is the opposite.\n\n`cases.interruptions` names what the app may put in the way of any step \u2014 a\nlock screen, a consent sheet \u2014 and the steps that get past it:\n`{ \"see\": \"#unlock-keypad\", \"do\": [\"app.unlock\"], \"unless\": \"keypad\" }`. It\nruns before a press, a type, a swipe or a see finds it there; a step whose own\nwords match `unless` is about it, and is left alone.\n\nCases and fixtures often live in a repository of their own: `LYNKEUS_CONFIG`\nnames a second configuration file, read over the app's, and the paths under\nits `cases` are relative to it.\n";
8
- export declare const FIXTURES = "#!/usr/bin/env node\n// The fixtures of this project: how a case prepares the backend and reads it.\n// lynkeus starts this once per run and talks JSON lines to it: one line in per\n// step, one line out. Replace the bodies with calls to your backend \u2014 its\n// admin API, a script, a database seed.\nimport readline from 'node:readline';\n\nconst users = new Map();\n\nconst fixtures = {\n async create_user({ name = 'Test user' }) {\n const user = { user_id: `u_${users.size + 1}`, name, points: 0 };\n users.set(user.user_id, user);\n return user;\n },\n async give_points({ user_id, points }) {\n const user = users.get(user_id);\n if (!user) throw new Error(`no user ${user_id}`);\n user.points += Number(points);\n return { user_id, points: user.points };\n },\n // What `assert` reads.\n async inspect({ user_id }) {\n return users.get(user_id) ?? {};\n },\n};\n\nfor await (const line of readline.createInterface({ input: process.stdin })) {\n if (!line.trim()) continue;\n const { command, params } = JSON.parse(line);\n try {\n const fixture = fixtures[command];\n if (!fixture) throw new Error(`no fixture ${command}`);\n console.log(JSON.stringify({ ok: true, ...(await fixture(params ?? {})) }));\n } catch (error) {\n console.log(JSON.stringify({ ok: false, error: error.message }));\n }\n}\n";
7
+ export declare const FORMAT = "# Cases\n\nA case is a markdown file: frontmatter that names it, then `setup`, `steps`\nand `expected`. `lynkeus case run cases/example.md` runs one; without files\nit runs every case in this folder. A step that fails stops the case, and the\nreport carries the screen, the requests and the reason.\n\n## Steps that drive the app\n\n| Step | Value | |\n|---|---|---|\n| `app.press` | target, or `{ target, within?, nth?, optional?, hold?, timeoutMs? }` | Waits for the control to be there and enabled. `optional` presses only if it is on screen. |\n| `app.type` | `{ text, target?, submit?, clear?, timeoutMs? }` | `target` is tapped first, once it is there (`timeoutMs` is how long that may take); `submit` ends with the return key. |\n| `app.swipe` | `{ direction, target?, distance? }` or `{ from, to }` | |\n| `app.wait` | route, target, ms, `{ target, scroll? }` or `{ request, status? }` | `scroll` swipes until the target is in the window. A `request` is `METHOD /path`. |\n| `app.see` | target, or `{ testId?, text?, route?, request?, event?, layer?, absent?, timeoutMs? }` | An assertion on what the app shows or did. Everything named must hold. |\n| `app.nav` | route, or `{ route, params }` | A teleport, for getting to where the case starts. |\n| `app.back` | | |\n| `app.requests` | `mark` | A `request` counts only what the app asked for after the step before it began; this marks further back, until a step has looked for a request. |\n| `app.call` | `{ command, params?, expect?, as? }` | A command the app registered with `qa.register`. |\n| `app.run` | `{ flow, vars? }` | A lynkeus flow, from the flows folder. |\n| `app.events` / `app.event` | `clear` / `{ name, props?, absent? }` | What the app told its analytics. |\n| `app.<command>` | its arguments by name | Any other lynkeus command: `app.mock`, `app.network`, `app.appstate`, `app.deeplink`\u2026 |\n\nA target is `#testId`, a text, an index or `x,y`. Where a step takes `target` it also takes `testId`. A text inside quotes of its own (`'\"Next\"'`) is always a text, where `app.wait: Next` would wait for a route called `Next`.\n\nA case that runs over several inputs declares `matrix: { locale: [en, fr] }`\nin its frontmatter and runs once per combination; `{{matrix.locale}}` is that\nrun's value.\n\nOne case runs on a host and on a device. What only one of them needs \u2014 closing a\nnative screen the host never shows \u2014 is kept to it:\n\n```yaml\n - on: device # or headless, ios, android\n do:\n - app.press: \"364,84\"\n```\n\n## Steps that prepare and read the backend\n\nAny other step name is a **fixture**: lynkeus sends it to the command in\n`cases.fixtures` (lynkeus.config.json) as one JSON line and reads one back.\n\n```\n\u2192 {\"command\": \"create_user\", \"params\": {\"name\": \"Ana\"}, \"env\": {\"NAME\": \"Ana\"}}\n\u2190 {\"ok\": true, \"user_id\": \"u_1\", \"name\": \"Ana\"}\n```\n\nBefore the first case lynkeus sends `{\"command\": \"lynkeus.describe\"}`. A\nserver that answers `{\"commands\": [\"create_user\", \u2026]}` gets its cases checked\nfirst: one that names a fixture missing from the list is refused before\nanything runs. A server that answers `\"ok\": false` is not checked.\n\n`as: u` keeps the answer; `{{u.user_id}}` reads it later. `assert` calls\nthe `inspect` fixture and checks one path of its answer with `equals`,\n`includes`, `gte`, `lte`, `near`, `matches`, `present` or `absent`.\n`exec: { run: \"\u2026\", as? }` runs a shell command and keeps its JSON output.\n\n## Steps of your own\n\n`cases.macros` names a step and the steps it stands for. Inside,\n`{{param.x}}` is what the case passed, and a parameter that names an alias\nis that alias.\n\n```json\n\"macros\": {\n \"app.login\": [\n { \"app.call\": { \"command\": \"reset\" } },\n { \"app.deeplink\": \"myapp://dev-login?user={{param.as.user_id}}\" },\n { \"when\": \"wait\", \"do\": { \"app.wait\": \"{{param.wait}}\" } }\n ]\n}\n```\n\n`when` runs a step only if the case passed that parameter (`\"when\": \"wait\"`)\nor passed that value (`\"when\": \"method=phone\"`); `unless` is the opposite.\n\n`cases.interruptions` names what the app may put in the way of any step \u2014 a\nlock screen, a consent sheet \u2014 and the steps that get past it:\n`{ \"see\": \"#unlock-keypad\", \"do\": [\"app.unlock\"], \"unless\": \"keypad\" }`. It\nruns before a press, a type, a swipe or a see finds it there; a step whose own\nwords match `unless` is about it, and is left alone.\n\nCases and fixtures often live in a repository of their own: `LYNKEUS_CONFIG`\nnames a second configuration file, read over the app's, and the paths under\nits `cases` are relative to it.\n";
8
+ export declare const FIXTURES = "#!/usr/bin/env node\n// The fixtures of this project: how a case prepares the backend and reads it.\n// lynkeus starts this once per run and talks JSON lines to it: one line in per\n// step, one line out. Replace the bodies with calls to your backend \u2014 its\n// admin API, a script, a database seed.\nimport readline from 'node:readline';\n\nconst users = new Map();\n\nconst fixtures = {\n async create_user({ name = 'Test user' }) {\n const user = { user_id: `u_${users.size + 1}`, name, points: 0 };\n users.set(user.user_id, user);\n return user;\n },\n async give_points({ user_id, points }) {\n const user = users.get(user_id);\n if (!user) throw new Error(`no user ${user_id}`);\n user.points += Number(points);\n return { user_id, points: user.points };\n },\n // What `assert` reads.\n async inspect({ user_id }) {\n return users.get(user_id) ?? {};\n },\n};\n\nfor await (const line of readline.createInterface({ input: process.stdin })) {\n if (!line.trim()) continue;\n const { command, params } = JSON.parse(line);\n // lynkeus asks once what this server answers, and refuses a case that names anything else.\n if (command === 'lynkeus.describe') {\n console.log(JSON.stringify({ commands: Object.keys(fixtures) }));\n continue;\n }\n try {\n const fixture = fixtures[command];\n if (!fixture) throw new Error(`no fixture ${command}`);\n console.log(JSON.stringify({ ok: true, ...(await fixture(params ?? {})) }));\n } catch (error) {\n console.log(JSON.stringify({ ok: false, error: error.message }));\n }\n}\n";
9
9
  export declare const CONFIG: {
10
10
  dir: string;
11
11
  flows: string;
@@ -35,20 +35,20 @@ report carries the screen, the requests and the reason.
35
35
 
36
36
  | Step | Value | |
37
37
  |---|---|---|
38
- | \`app.press\` | target, or \`{ target, within?, nth?, optional?, hold? }\` | Waits for the control to be there and enabled. \`optional\` presses only if it is on screen. |
39
- | \`app.type\` | \`{ text, target?, submit?, clear? }\` | \`target\` is tapped first; \`submit\` ends with the return key. |
38
+ | \`app.press\` | target, or \`{ target, within?, nth?, optional?, hold?, timeoutMs? }\` | Waits for the control to be there and enabled. \`optional\` presses only if it is on screen. |
39
+ | \`app.type\` | \`{ text, target?, submit?, clear?, timeoutMs? }\` | \`target\` is tapped first, once it is there (\`timeoutMs\` is how long that may take); \`submit\` ends with the return key. |
40
40
  | \`app.swipe\` | \`{ direction, target?, distance? }\` or \`{ from, to }\` | |
41
41
  | \`app.wait\` | route, target, ms, \`{ target, scroll? }\` or \`{ request, status? }\` | \`scroll\` swipes until the target is in the window. A \`request\` is \`METHOD /path\`. |
42
42
  | \`app.see\` | target, or \`{ testId?, text?, route?, request?, event?, layer?, absent?, timeoutMs? }\` | An assertion on what the app shows or did. Everything named must hold. |
43
43
  | \`app.nav\` | route, or \`{ route, params }\` | A teleport, for getting to where the case starts. |
44
44
  | \`app.back\` | | |
45
- | \`app.requests\` | \`mark\` | From here on, \`request\` only counts what the app asks for. |
45
+ | \`app.requests\` | \`mark\` | A \`request\` counts only what the app asked for after the step before it began; this marks further back, until a step has looked for a request. |
46
46
  | \`app.call\` | \`{ command, params?, expect?, as? }\` | A command the app registered with \`qa.register\`. |
47
47
  | \`app.run\` | \`{ flow, vars? }\` | A lynkeus flow, from the flows folder. |
48
48
  | \`app.events\` / \`app.event\` | \`clear\` / \`{ name, props?, absent? }\` | What the app told its analytics. |
49
49
  | \`app.<command>\` | its arguments by name | Any other lynkeus command: \`app.mock\`, \`app.network\`, \`app.appstate\`, \`app.deeplink\`… |
50
50
 
51
- A target is \`#testId\`, \`"text"\`, an index or \`x,y\`.
51
+ A target is \`#testId\`, a text, an index or \`x,y\`. Where a step takes \`target\` it also takes \`testId\`. A text inside quotes of its own (\`'"Next"'\`) is always a text, where \`app.wait: Next\` would wait for a route called \`Next\`.
52
52
 
53
53
  A case that runs over several inputs declares \`matrix: { locale: [en, fr] }\`
54
54
  in its frontmatter and runs once per combination; \`{{matrix.locale}}\` is that
@@ -73,6 +73,11 @@ Any other step name is a **fixture**: lynkeus sends it to the command in
73
73
  ← {"ok": true, "user_id": "u_1", "name": "Ana"}
74
74
  \`\`\`
75
75
 
76
+ Before the first case lynkeus sends \`{"command": "lynkeus.describe"}\`. A
77
+ server that answers \`{"commands": ["create_user", …]}\` gets its cases checked
78
+ first: one that names a fixture missing from the list is refused before
79
+ anything runs. A server that answers \`"ok": false\` is not checked.
80
+
76
81
  \`as: u\` keeps the answer; \`{{u.user_id}}\` reads it later. \`assert\` calls
77
82
  the \`inspect\` fixture and checks one path of its answer with \`equals\`,
78
83
  \`includes\`, \`gte\`, \`lte\`, \`near\`, \`matches\`, \`present\` or \`absent\`.
@@ -137,6 +142,11 @@ const fixtures = {
137
142
  for await (const line of readline.createInterface({ input: process.stdin })) {
138
143
  if (!line.trim()) continue;
139
144
  const { command, params } = JSON.parse(line);
145
+ // lynkeus asks once what this server answers, and refuses a case that names anything else.
146
+ if (command === 'lynkeus.describe') {
147
+ console.log(JSON.stringify({ commands: Object.keys(fixtures) }));
148
+ continue;
149
+ }
140
150
  try {
141
151
  const fixture = fixtures[command];
142
152
  if (!fixture) throw new Error(\`no fixture \${command}\`);
@@ -0,0 +1,58 @@
1
+ /** A span as a backend exported it over OTLP, reduced to what a report shows. */
2
+ export type Span = {
3
+ traceId: string;
4
+ spanId: string;
5
+ parentSpanId?: string;
6
+ name: string;
7
+ kind: number;
8
+ startNs: bigint;
9
+ endNs: bigint;
10
+ attributes: Record<string, string | number | boolean>;
11
+ error?: string;
12
+ service?: string;
13
+ };
14
+ /** What a step did on the backend, as the run file keeps it: one line per span, children indented by depth. */
15
+ export type BackendSpan = {
16
+ name: string;
17
+ /** server, client, internal, producer or consumer */
18
+ kind: string;
19
+ /** ms after the step began */
20
+ at: number;
21
+ ms: number;
22
+ depth: number;
23
+ service?: string;
24
+ error?: string;
25
+ attributes?: Record<string, string | number | boolean>;
26
+ };
27
+ export declare const newTraceId: () => string;
28
+ export declare const newSpanId: () => string;
29
+ export declare const traceparent: (traceId: string, spanId: string) => string;
30
+ export declare const decodeProtobuf: (buf: Uint8Array) => Span[];
31
+ export declare const decodeJson: (body: unknown) => Span[];
32
+ /**
33
+ * Listens for OTLP traces while a run goes on: a backend with OTEL_EXPORTER_OTLP_ENDPOINT pointed here sends
34
+ * what each request did, and the steps that sent those requests (their ids travel in `traceparent`) get it.
35
+ */
36
+ export declare class TraceSink {
37
+ readonly port: number;
38
+ private readonly spans;
39
+ private server?;
40
+ private readonly arrived;
41
+ constructor(port: number);
42
+ start(): Promise<void>;
43
+ add(spans: Span[]): void;
44
+ /**
45
+ * Backends export in batches, a moment after the requests they served: wait until the trace has had
46
+ * nothing new for `quietMs`, or `maxMs` has passed. Other traces (a scheduler polling) do not count.
47
+ */
48
+ settle(traceId: string, quietMs?: number, maxMs?: number): Promise<void>;
49
+ /**
50
+ * The spans of one trace, each under the step whose id its chain of parents reaches: `steps` maps a step's
51
+ * span id to its index and the epoch ms it began. Spans of the trace that reach no step are left out.
52
+ */
53
+ take(traceId: string, steps: Map<string, {
54
+ index: number;
55
+ startedMs: number;
56
+ }>): Map<number, BackendSpan[]>;
57
+ close(): void;
58
+ }
@@ -0,0 +1,250 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import http from 'node:http';
3
+ const KINDS = ['unspecified', 'internal', 'server', 'client', 'producer', 'consumer'];
4
+ export const newTraceId = () => randomBytes(16).toString('hex');
5
+ export const newSpanId = () => randomBytes(8).toString('hex');
6
+ export const traceparent = (traceId, spanId) => `00-${traceId}-${spanId}-01`;
7
+ const fields = (buf) => {
8
+ const out = [];
9
+ let i = 0;
10
+ const varint = () => {
11
+ let result = 0n;
12
+ let shift = 0n;
13
+ for (;;) {
14
+ const b = buf[i++];
15
+ result |= BigInt(b & 0x7f) << shift;
16
+ if (!(b & 0x80))
17
+ return result;
18
+ shift += 7n;
19
+ }
20
+ };
21
+ while (i < buf.length) {
22
+ const tag = Number(varint());
23
+ const no = tag >> 3;
24
+ const wire = tag & 7;
25
+ if (wire === 0)
26
+ out.push({ no, wire, value: varint() });
27
+ else if (wire === 1) {
28
+ out.push({ no, wire, value: buf.slice(i, i + 8) });
29
+ i += 8;
30
+ }
31
+ else if (wire === 2) {
32
+ const length = Number(varint());
33
+ out.push({ no, wire, value: buf.subarray(i, i + length) });
34
+ i += length;
35
+ }
36
+ else if (wire === 5) {
37
+ out.push({ no, wire, value: buf.slice(i, i + 4) });
38
+ i += 4;
39
+ }
40
+ else
41
+ throw new Error(`unknown protobuf wire type ${wire}`);
42
+ }
43
+ return out;
44
+ };
45
+ const text = (v) => (v instanceof Uint8Array ? Buffer.from(v).toString('utf8') : '');
46
+ const hex = (v) => (v instanceof Uint8Array ? Buffer.from(v).toString('hex') : '');
47
+ const fixed64 = (v) => (v instanceof Uint8Array ? Buffer.from(v).readBigUInt64LE(0) : 0n);
48
+ const anyValue = (buf) => {
49
+ for (const f of fields(buf)) {
50
+ if (f.no === 1)
51
+ return text(f.value);
52
+ if (f.no === 2)
53
+ return f.value === 1n;
54
+ if (f.no === 3)
55
+ return Number(BigInt.asIntN(64, f.value));
56
+ if (f.no === 4 && f.value instanceof Uint8Array)
57
+ return Buffer.from(f.value).readDoubleLE(0);
58
+ }
59
+ return '';
60
+ };
61
+ const keyValues = (list, no) => {
62
+ const out = {};
63
+ for (const f of list) {
64
+ if (f.no !== no || !(f.value instanceof Uint8Array))
65
+ continue;
66
+ let key = '';
67
+ let value = '';
68
+ for (const kv of fields(f.value)) {
69
+ if (kv.no === 1)
70
+ key = text(kv.value);
71
+ if (kv.no === 2 && kv.value instanceof Uint8Array)
72
+ value = anyValue(kv.value);
73
+ }
74
+ if (key)
75
+ out[key] = value;
76
+ }
77
+ return out;
78
+ };
79
+ export const decodeProtobuf = (buf) => {
80
+ const spans = [];
81
+ for (const rs of fields(buf)) {
82
+ if (rs.no !== 1 || !(rs.value instanceof Uint8Array))
83
+ continue;
84
+ const parts = fields(rs.value);
85
+ const resource = parts.find((p) => p.no === 1);
86
+ const service = resource?.value instanceof Uint8Array ? keyValues(fields(resource.value), 1)['service.name'] : undefined;
87
+ for (const ss of parts) {
88
+ if (ss.no !== 2 || !(ss.value instanceof Uint8Array))
89
+ continue;
90
+ for (const sp of fields(ss.value)) {
91
+ if (sp.no !== 2 || !(sp.value instanceof Uint8Array))
92
+ continue;
93
+ const s = fields(sp.value);
94
+ const get = (no) => s.find((f) => f.no === no)?.value;
95
+ const status = get(15);
96
+ let error;
97
+ if (status instanceof Uint8Array) {
98
+ const st = fields(status);
99
+ const code = st.find((f) => f.no === 3)?.value;
100
+ if (code === 2n)
101
+ error = text(st.find((f) => f.no === 2)?.value ?? new Uint8Array()) || 'error';
102
+ }
103
+ const parent = get(4);
104
+ spans.push({
105
+ traceId: hex(get(1) ?? new Uint8Array()),
106
+ spanId: hex(get(2) ?? new Uint8Array()),
107
+ parentSpanId: parent instanceof Uint8Array && parent.length ? hex(parent) : undefined,
108
+ name: text(get(5) ?? new Uint8Array()),
109
+ kind: Number(get(6) ?? 0n),
110
+ startNs: fixed64(get(7) ?? new Uint8Array(8)),
111
+ endNs: fixed64(get(8) ?? new Uint8Array(8)),
112
+ attributes: keyValues(s, 9),
113
+ ...(error ? { error } : {}),
114
+ ...(typeof service === 'string' ? { service } : {}),
115
+ });
116
+ }
117
+ }
118
+ }
119
+ return spans;
120
+ };
121
+ const jsonAttrs = (list) => Object.fromEntries((list ?? []).map(({ key, value }) => [
122
+ key,
123
+ value.stringValue ?? value.boolValue ?? (value.intValue !== undefined ? Number(value.intValue) : (value.doubleValue ?? '')),
124
+ ]));
125
+ export const decodeJson = (body) => {
126
+ const spans = [];
127
+ const request = body;
128
+ for (const rs of request.resourceSpans ?? []) {
129
+ const service = jsonAttrs(rs.resource?.attributes)['service.name'];
130
+ for (const ss of rs.scopeSpans ?? [])
131
+ for (const s of ss.spans ?? [])
132
+ spans.push({
133
+ traceId: s.traceId.toLowerCase(),
134
+ spanId: s.spanId.toLowerCase(),
135
+ parentSpanId: s.parentSpanId ? s.parentSpanId.toLowerCase() : undefined,
136
+ name: s.name,
137
+ kind: s.kind ?? 0,
138
+ startNs: BigInt(s.startTimeUnixNano),
139
+ endNs: BigInt(s.endTimeUnixNano),
140
+ attributes: jsonAttrs(s.attributes),
141
+ ...(s.status?.code === 2 ? { error: s.status.message || 'error' } : {}),
142
+ ...(typeof service === 'string' ? { service } : {}),
143
+ });
144
+ }
145
+ return spans;
146
+ };
147
+ // What a report keeps of a span's attributes: what it did, not every label an instrumentation adds.
148
+ const KEEP = /^(http\.(request\.)?method|http\.(response\.)?status_code|http\.route|http\.target|url\.(path|full)|server\.address|net\.peer\.name|db\.(system|statement|operation|sql\.table)|messaging\.|code\.(namespace|function)|rpc\.|exception\.message|enduser\.id)/;
149
+ /**
150
+ * Listens for OTLP traces while a run goes on: a backend with OTEL_EXPORTER_OTLP_ENDPOINT pointed here sends
151
+ * what each request did, and the steps that sent those requests (their ids travel in `traceparent`) get it.
152
+ */
153
+ export class TraceSink {
154
+ port;
155
+ spans = new Map();
156
+ server;
157
+ arrived = new Map();
158
+ constructor(port) {
159
+ this.port = port;
160
+ }
161
+ async start() {
162
+ this.server = http.createServer((req, res) => {
163
+ if (req.method !== 'POST' || !req.url?.startsWith('/v1/traces')) {
164
+ res.writeHead(404).end();
165
+ return;
166
+ }
167
+ const chunks = [];
168
+ req.on('data', (c) => chunks.push(c));
169
+ req.on('end', () => {
170
+ try {
171
+ const body = Buffer.concat(chunks);
172
+ const spans = (req.headers['content-type'] ?? '').includes('json') ? decodeJson(JSON.parse(body.toString('utf8'))) : decodeProtobuf(body);
173
+ this.add(spans);
174
+ res.writeHead(200, { 'content-type': req.headers['content-type'] ?? 'application/x-protobuf' }).end();
175
+ }
176
+ catch (error) {
177
+ res.writeHead(400).end(String(error));
178
+ }
179
+ });
180
+ });
181
+ await new Promise((resolve, reject) => {
182
+ this.server.once('error', reject);
183
+ this.server.listen(this.port, '127.0.0.1', () => resolve());
184
+ });
185
+ }
186
+ add(spans) {
187
+ for (const span of spans) {
188
+ this.arrived.set(span.traceId, Date.now());
189
+ const list = this.spans.get(span.traceId) ?? [];
190
+ list.push(span);
191
+ this.spans.set(span.traceId, list);
192
+ }
193
+ }
194
+ /**
195
+ * Backends export in batches, a moment after the requests they served: wait until the trace has had
196
+ * nothing new for `quietMs`, or `maxMs` has passed. Other traces (a scheduler polling) do not count.
197
+ */
198
+ async settle(traceId, quietMs = 300, maxMs = 3000) {
199
+ const started = Date.now();
200
+ while (Date.now() - started < maxMs) {
201
+ if (Date.now() - Math.max(this.arrived.get(traceId) ?? 0, started) >= quietMs)
202
+ return;
203
+ await new Promise((r) => setTimeout(r, 50));
204
+ }
205
+ }
206
+ /**
207
+ * The spans of one trace, each under the step whose id its chain of parents reaches: `steps` maps a step's
208
+ * span id to its index and the epoch ms it began. Spans of the trace that reach no step are left out.
209
+ */
210
+ take(traceId, steps) {
211
+ const spans = this.spans.get(traceId) ?? [];
212
+ this.spans.delete(traceId);
213
+ this.arrived.delete(traceId);
214
+ const byId = new Map(spans.map((s) => [s.spanId, s]));
215
+ const out = new Map();
216
+ const place = (span) => {
217
+ let depth = 0;
218
+ for (let at = span; at; depth++) {
219
+ const step = at.parentSpanId ? steps.get(at.parentSpanId) : undefined;
220
+ if (step)
221
+ return { step: step.index, depth };
222
+ at = at.parentSpanId ? byId.get(at.parentSpanId) : undefined;
223
+ }
224
+ return undefined;
225
+ };
226
+ for (const span of [...spans].sort((a, b) => (a.startNs < b.startNs ? -1 : 1))) {
227
+ const where = place(span);
228
+ if (!where)
229
+ continue;
230
+ const startedMs = [...steps.values()].find((s) => s.index === where.step).startedMs;
231
+ const attributes = Object.fromEntries(Object.entries(span.attributes).filter(([k]) => KEEP.test(k)));
232
+ const list = out.get(where.step) ?? [];
233
+ list.push({
234
+ name: span.name,
235
+ kind: KINDS[span.kind] ?? 'internal',
236
+ at: Math.max(0, Math.round(Number(span.startNs / 1000000n) - startedMs)),
237
+ ms: Math.round(Number(span.endNs - span.startNs) / 1e5) / 10,
238
+ depth: where.depth,
239
+ ...(span.service ? { service: span.service } : {}),
240
+ ...(span.error ? { error: span.error } : {}),
241
+ ...(Object.keys(attributes).length ? { attributes } : {}),
242
+ });
243
+ out.set(where.step, list);
244
+ }
245
+ return out;
246
+ }
247
+ close() {
248
+ this.server?.close();
249
+ }
250
+ }
package/lib/cli.js CHANGED
@@ -8,13 +8,15 @@ import { driveCommands } from './commands/drive.js';
8
8
  import { headlessCommands } from './commands/headless.js';
9
9
  import { mapCommands } from './commands/map.js';
10
10
  import { osCommands } from './commands/os.js';
11
+ import { reportCommands } from './commands/report.js';
11
12
  import { runsCommands } from './commands/runs.js';
12
- import { graphFile, loadConfig, loadGraph, portFromEnv } from './config.js';
13
- import { HEADLESS_HOST, pickAgentDevice } from './device/devices.js';
13
+ import { graphFile, loadConfig, loadGraph, portFromEnv, readToken } from './config.js';
14
+ import { agentMismatch } from './device/agentVersion.js';
15
+ import { HEADLESS_HOST, pickAgentDevice, pickAndroidDevice } from './device/devices.js';
14
16
  import { Device } from './device/driver.js';
15
17
  import { commands, find, helpFor, inSession, referenceMarkdown, register, resolve, usage } from './registry.js';
16
18
  import { execute } from './runtime.js';
17
- register([...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...caseCommands, ...headlessCommands]);
19
+ register([...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...caseCommands, ...reportCommands, ...headlessCommands]);
18
20
  /** The global flags, read leniently from the whole argv before any command parses its own. */
19
21
  const globals = (argv) => {
20
22
  const out = {};
@@ -35,7 +37,10 @@ const globals = (argv) => {
35
37
  }
36
38
  return out;
37
39
  };
38
- const contextFor = (argv, attached = false) => {
40
+ // Once per process: a suite reattaches for every case.
41
+ let mismatchTold = false;
42
+ // `ownHost`: the port belongs to a host this run started for a case; whatever dials in is the device.
43
+ const contextFor = (argv, attached = false, port, ownHost = false) => {
39
44
  const g = globals(argv);
40
45
  const root = path.resolve(g.root ?? process.cwd());
41
46
  const config = loadConfig(root);
@@ -46,8 +51,18 @@ const contextFor = (argv, attached = false) => {
46
51
  const appId = g.app ?? process.env.LYNKEUS_APP_ID ?? config.app;
47
52
  const started = performance.now();
48
53
  const attachedIn = (d) => err(`attached in ${Math.round(performance.now() - started)}ms (${d.info.name})`);
49
- const server = new AgentServer({ token: process.env.LYNKEUS_TOKEN, port: portFromEnv(config) });
50
- const picking = pickAgentDevice(g.device ?? process.env.LYNKEUS_DEVICE ?? config.device);
54
+ const server = new AgentServer({ token: readToken(root), port: port ?? portFromEnv(config) });
55
+ server.on('hello', (hello) => {
56
+ const told = mismatchTold ? undefined : agentMismatch(hello);
57
+ if (!told)
58
+ return;
59
+ mismatchTold = true;
60
+ err(told);
61
+ });
62
+ // A host started for the case is the device: looking for the configured simulator would only replace a
63
+ // late app's error with one about a simulator nobody asked for.
64
+ const wantedDevice = g.device ?? process.env.LYNKEUS_DEVICE ?? config.device;
65
+ const picking = ownHost ? Promise.resolve(HEADLESS_HOST) : pickAgentDevice(wantedDevice);
51
66
  if (g.launch && appId) {
52
67
  const d = new Device(await picking, { appId, server });
53
68
  await d.launchApp(appId, { fresh: true });
@@ -70,7 +85,9 @@ const contextFor = (argv, attached = false) => {
70
85
  name: headless ? HEADLESS_HOST.name : String(hello.app?.name ?? hello.platform),
71
86
  platform: hello.platform === 'android' ? 'android' : 'ios',
72
87
  };
73
- const d = new Device(info, { appId: appId ?? null, server, resolving: headless ? undefined : picking });
88
+ // The app says which platform it runs on; only then is it known whether simctl or adb names its device.
89
+ const resolving = headless || ownHost ? undefined : info.platform === 'android' ? pickAndroidDevice(wantedDevice) : picking;
90
+ const d = new Device(info, { appId: appId ?? null, server, resolving });
74
91
  attachedIn(d);
75
92
  return d;
76
93
  };
@@ -89,6 +106,10 @@ const contextFor = (argv, attached = false) => {
89
106
  device ??= connect();
90
107
  return device;
91
108
  },
109
+ forPort: (other) => {
110
+ const scoped = contextFor(argv, attached, other, true);
111
+ return { base: scoped.ctx, stop: scoped.stop };
112
+ },
92
113
  };
93
114
  return {
94
115
  ctx,