lynkeus 0.3.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 (89) 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 +58 -1
  16. package/lib/cases/runner.js +322 -39
  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 +27 -4
  21. package/lib/commands/cases.d.ts +8 -0
  22. package/lib/commands/cases.js +154 -25
  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/doctor.js +8 -2
  29. package/lib/commands/drive.js +3 -1
  30. package/lib/commands/fixtures.d.ts +7 -0
  31. package/lib/commands/fixtures.js +94 -0
  32. package/lib/commands/headless.d.ts +11 -0
  33. package/lib/commands/headless.js +189 -9
  34. package/lib/commands/init.d.ts +23 -0
  35. package/lib/commands/init.js +231 -26
  36. package/lib/commands/map.d.ts +2 -0
  37. package/lib/commands/map.js +18 -14
  38. package/lib/commands/os.js +43 -10
  39. package/lib/commands/report.d.ts +4 -0
  40. package/lib/commands/report.js +156 -20
  41. package/lib/config.d.ts +41 -0
  42. package/lib/config.js +30 -0
  43. package/lib/device/agentVersion.d.ts +7 -0
  44. package/lib/device/agentVersion.js +14 -0
  45. package/lib/device/devices.js +6 -0
  46. package/lib/device/driver.js +2 -2
  47. package/lib/knowledge/analyticsTraffic.d.ts +10 -0
  48. package/lib/knowledge/analyticsTraffic.js +109 -0
  49. package/lib/knowledge/errors.js +6 -0
  50. package/lib/knowledge/events.d.ts +2 -0
  51. package/lib/knowledge/why.d.ts +2 -0
  52. package/lib/knowledge/why.js +1 -1
  53. package/lib/mcp/server.js +45 -4
  54. package/lib/runtime.d.ts +9 -1
  55. package/lib/runtime.js +25 -3
  56. package/package.json +7 -4
  57. package/src/cases/coverage.ts +99 -0
  58. package/src/cases/draft.ts +75 -0
  59. package/src/cases/fixtures.ts +57 -8
  60. package/src/cases/frames.ts +30 -0
  61. package/src/cases/nativeLogs.ts +112 -0
  62. package/src/cases/runDirs.ts +48 -0
  63. package/src/cases/runfile.ts +64 -4
  64. package/src/cases/runner.ts +372 -42
  65. package/src/cases/templates.ts +20 -2
  66. package/src/cases/traces.ts +3 -1
  67. package/src/cli.ts +27 -4
  68. package/src/commands/cases.ts +165 -23
  69. package/src/commands/ci.ts +189 -0
  70. package/src/commands/coverage.ts +147 -0
  71. package/src/commands/device.ts +27 -2
  72. package/src/commands/doctor.ts +9 -2
  73. package/src/commands/drive.ts +3 -1
  74. package/src/commands/fixtures.ts +102 -0
  75. package/src/commands/headless.ts +197 -9
  76. package/src/commands/init.ts +270 -26
  77. package/src/commands/map.ts +20 -15
  78. package/src/commands/os.ts +38 -10
  79. package/src/commands/report.ts +162 -18
  80. package/src/config.ts +48 -0
  81. package/src/device/agentVersion.ts +17 -0
  82. package/src/device/devices.ts +5 -0
  83. package/src/device/driver.ts +2 -2
  84. package/src/knowledge/analyticsTraffic.ts +142 -0
  85. package/src/knowledge/errors.ts +6 -0
  86. package/src/knowledge/events.ts +2 -1
  87. package/src/knowledge/why.ts +3 -1
  88. package/src/mcp/server.ts +45 -4
  89. package/src/runtime.ts +24 -3
@@ -14,8 +14,19 @@ export const toRunCase = (c, { report, attempt, startedAt }, root, dir) => {
14
14
  // The runner numbers an event's step from 1, in the order the steps ran.
15
15
  const told = (report.events ?? [])
16
16
  .filter((e) => e.step === index + 1)
17
- .map((e) => ({ origin: 'app', name: e.name, ...(e.props ? { props: e.props } : {}), ...(e.route ? { route: e.route } : {}) }));
18
- const sent = (s.sent ?? []).map((e) => ({ origin: 'app', provider: e.provider, name: e.name, ...(e.props ? { props: e.props } : {}) }));
17
+ .map((e) => ({
18
+ origin: 'app',
19
+ ...(e.provider ? { provider: e.provider } : {}),
20
+ name: e.name,
21
+ ...(e.props ? { props: e.props } : {}),
22
+ ...(e.route ? { route: e.route } : {}),
23
+ }));
24
+ const sent = (s.sent ?? []).map((e) => ({
25
+ origin: e.via === 'server' ? 'server' : 'app',
26
+ provider: e.provider,
27
+ name: e.name,
28
+ ...(e.props ? { props: e.props } : {}),
29
+ }));
19
30
  const events = [...told, ...sent];
20
31
  const screenshot = within(dir, s.screenshot);
21
32
  return {
@@ -28,6 +39,8 @@ export const toRunCase = (c, { report, attempt, startedAt }, root, dir) => {
28
39
  ...(screenshot ? { screenshot } : {}),
29
40
  ...(s.videoS !== undefined ? { videoS: s.videoS } : {}),
30
41
  ...(s.backend?.length ? { backend: s.backend } : {}),
42
+ ...(s.requests?.length ? { requests: s.requests } : {}),
43
+ ...(s.logs?.length ? { logs: s.logs } : {}),
31
44
  };
32
45
  });
33
46
  const description = typeof c.meta.description === 'string' ? c.meta.description : undefined;
@@ -36,6 +49,7 @@ export const toRunCase = (c, { report, attempt, startedAt }, root, dir) => {
36
49
  id: report.id,
37
50
  ...(report.title ? { title: report.title } : {}),
38
51
  ...(description ? { description } : {}),
52
+ ...(report.about ? { about: report.about } : {}),
39
53
  file: path.relative(root, path.resolve(root, report.file)),
40
54
  attempt,
41
55
  result: report.result,
@@ -48,7 +62,7 @@ export const toRunCase = (c, { report, attempt, startedAt }, root, dir) => {
48
62
  };
49
63
  };
50
64
  /** The branch and commit the run was made from, so a report can say which code it tested. */
51
- const gitOf = (root) => {
65
+ export const gitOf = (root) => {
52
66
  const ask = (...args) => {
53
67
  try {
54
68
  return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined;
@@ -61,7 +75,56 @@ const gitOf = (root) => {
61
75
  if (!commit)
62
76
  return undefined;
63
77
  const branch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || ask('rev-parse', '--abbrev-ref', 'HEAD');
64
- return { commit, ...(branch && branch !== 'HEAD' ? { branch } : {}) };
78
+ const remote = webOf(ask('remote', 'get-url', 'origin'));
79
+ // What the run had that no commit has: a report says the app was not exactly the commit it names.
80
+ const changed = (ask('status', '--porcelain', '--untracked-files=no') ?? '')
81
+ .split('\n')
82
+ .map((l) => l.slice(3).trim())
83
+ .filter(Boolean);
84
+ // A branch that lives only on this machine names nothing a reader can open: the run names the remote branch
85
+ // that has its commit instead (the one it tracks first, else any), and keeps the local name beside it.
86
+ const local = branch && branch !== 'HEAD' ? branch : undefined;
87
+ const onRemote = (name) => !!ask('rev-parse', '--verify', '--quiet', `refs/remotes/origin/${name}`);
88
+ const holding = remote && local && !onRemote(local) ? remoteBranchWith(ask, commit) : undefined;
89
+ const shown = holding ?? local;
90
+ const pushed = remote ? { branch: !!shown && (shown === holding || onRemote(shown)), commit: !!ask('branch', '--remotes', '--contains', commit) } : undefined;
91
+ return {
92
+ commit,
93
+ ...(shown ? { branch: shown } : {}),
94
+ ...(holding && local ? { localBranch: local } : {}),
95
+ ...(remote ? { remote } : {}),
96
+ ...(pushed ? { pushed } : {}),
97
+ ...(changed.length ? { changed: changed.slice(0, 20), changedCount: changed.length } : {}),
98
+ };
99
+ };
100
+ /** The origin branch that holds `commit`: the one the checkout tracks when it does, else the first. */
101
+ const remoteBranchWith = (ask, commit) => {
102
+ const strip = (ref) => ref.replace(/^origin\//, '');
103
+ const upstream = ask('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}');
104
+ const holders = (ask('branch', '--remotes', '--contains', commit, '--format=%(refname:short)') ?? '')
105
+ .split('\n')
106
+ .map((l) => l.trim())
107
+ .filter((l) => l.startsWith('origin/') && l !== 'origin/HEAD' && l !== 'origin');
108
+ if (upstream && holders.includes(upstream))
109
+ return strip(upstream);
110
+ return holders[0] ? strip(holders[0]) : undefined;
111
+ };
112
+ /** A remote as a web address a report can link commits to, without any credentials it carried. */
113
+ export const webOf = (url) => {
114
+ if (!url)
115
+ return undefined;
116
+ const ssh = url.match(/^[\w.-]+@([\w.-]+):(.+?)(\.git)?$/);
117
+ if (ssh)
118
+ return `https://${ssh[1]}/${ssh[2]}`;
119
+ try {
120
+ const u = new URL(url);
121
+ if (u.protocol !== 'https:' && u.protocol !== 'http:' && u.protocol !== 'ssh:')
122
+ return undefined;
123
+ return `https://${u.hostname}${u.pathname.replace(/\.git$/, '')}`;
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
65
128
  };
66
129
  export class RunFile {
67
130
  dir;
@@ -1,3 +1,4 @@
1
+ import type { RequestRecord } from 'lynkeus-protocol';
1
2
  import { type AppEvent, type RunEvent } from '../knowledge/events.js';
2
3
  import { type Base, type Command } from '../registry.js';
3
4
  import type { Fixtures, FixturesConfig } from './fixtures.js';
@@ -34,6 +35,8 @@ export type CasesConfig = {
34
35
  /** `{ "user": "user_id" }` turns `user: u` into `user_id: <u.user_id>`, and gives a step that names no user the latest one. */
35
36
  refs?: Record<string, string>;
36
37
  /** Steps this project adds: `{ "app.login": [...] }`. Inside, `{{param.x}}` is what the case passed. */
38
+ /** The screen a case begins on, for drafts (`case new`): the first the app shows a signed-in person, say. */
39
+ start?: string;
37
40
  macros?: Record<string, unknown[]>;
38
41
  /** What the app may put in the way of a step and the steps that get past it; a step whose own words match `unless` is about it, and is left alone. */
39
42
  interruptions?: {
@@ -41,12 +44,27 @@ export type CasesConfig = {
41
44
  do: unknown[];
42
45
  unless?: string;
43
46
  }[];
47
+ /**
48
+ * What the backend told its own analytics while a step ran, for a report: after each app step the fixture is
49
+ * asked with `{ since }` (the step's start, ISO) and answers `{ events: [{ name | event, props | payload,
50
+ * provider? }] }`. `provider` names the service when the fixture does not (an Olympus that sends to Mixpanel).
51
+ */
52
+ serverEvents?: {
53
+ fixture: string;
54
+ provider?: string;
55
+ };
56
+ /** The service the app's own event list feeds (its `events` command), so a report names it instead of "App". */
57
+ appEvents?: {
58
+ provider: string;
59
+ };
44
60
  };
45
61
  /** What a host's analytics capture saw a step send: the SDK it went to and the event. */
62
+ /** `via`: the SDK's native module (a host that stands in for it), the request it sent, or the backend's own analytics. */
46
63
  export type SentEvent = {
47
64
  provider: string;
48
65
  name: string;
49
66
  props?: Record<string, unknown>;
67
+ via?: 'sdk' | 'network' | 'server';
50
68
  };
51
69
  /** `unsupported`: the host the case ran on has no such thing to do (a real device has no mocked responses), which says nothing about the app. */
52
70
  export type StepReport = {
@@ -62,6 +80,18 @@ export type StepReport = {
62
80
  sent?: SentEvent[];
63
81
  /** What the backend did for it, from the traces it exported to the run. */
64
82
  backend?: BackendSpan[];
83
+ /** The requests the app sent while it ran, with headers and bodies (secrets hidden), when the run keeps evidence. */
84
+ requests?: RequestRecord[];
85
+ /** What the app printed while it ran: its console and the errors it did not catch, when the run keeps evidence. */
86
+ logs?: LogLine[];
87
+ };
88
+ /** A line the app printed: `at` in ms since the epoch, `source` where it came from (`js`, `native`, `host`). */
89
+ export type LogLine = {
90
+ at: number;
91
+ level: 'debug' | 'info' | 'warn' | 'error';
92
+ source: string;
93
+ text: string;
94
+ stack?: string;
65
95
  };
66
96
  export type CaseReport = {
67
97
  id: string;
@@ -71,6 +101,17 @@ export type CaseReport = {
71
101
  result: 'passed' | 'failed' | 'unsupported';
72
102
  ms: number;
73
103
  steps: StepReport[];
104
+ /**
105
+ * What a reader needs before the steps: what the case proves (`description`), why it matters (`why`), what
106
+ * has to be true when it starts (`given`), the ticket it covers and the outcome it expects. From its frontmatter.
107
+ */
108
+ about?: {
109
+ description?: string;
110
+ why?: string;
111
+ given?: string;
112
+ ticket?: string;
113
+ expected?: string;
114
+ };
74
115
  /** When a step failed: the screen, the requests since the mark, and why. */
75
116
  evidence?: {
76
117
  screen?: string;
@@ -87,11 +128,20 @@ export type CaseReport = {
87
128
  diagnosis?: string[];
88
129
  };
89
130
  /** Where a case keeps what it can show of itself: a frame per step, a film. Each is taken only where the host can. */
131
+ /** `frames`: AVIF where the host writes it (a fifth of a PNG's bytes, lossy), or PNG, exact, for frames two runs are compared by. */
90
132
  export type Evidence = {
91
133
  dir: string;
92
134
  screenshots?: boolean;
135
+ settleMs?: number;
136
+ /** Wait for the screen to stand still: two frames alike this many ms apart, on a host's own clock where it offers
137
+ * that (`still`), or taken again on a device. */
138
+ stillMs?: number;
139
+ /** A looking step on an unchanged screen shares the last frame instead of taking one. */
140
+ onChange?: boolean;
141
+ frames?: 'avif' | 'png';
93
142
  video?: {
94
143
  fps: number;
144
+ scale?: number;
95
145
  };
96
146
  };
97
147
  export type RunOptions = {
@@ -113,7 +163,10 @@ export type RunOptions = {
113
163
  /** Keep running a case's steps after one fails, to see everything that is wrong in one pass. */
114
164
  continue?: boolean;
115
165
  /** Collect the screens the app went through, for a coverage map. `fresh`: the app was started for this case. */
116
- routes?: boolean | 'fresh';
166
+ routes?: boolean | 'fresh' /** The app's native log on a simulator or an emulator, read step by step into each step's logs. */;
167
+ nativeLogs?: {
168
+ take: () => LogLine[];
169
+ };
117
170
  };
118
171
  /** What a case's setup leaves for its steps: the aliases and what they answered, and what ran. */
119
172
  export type Prepared = {
@@ -135,4 +188,8 @@ export declare const argvFor: (command: Command, value: StepValue) => string[];
135
188
  export declare const missingFixtures: (c: Case, config: CasesConfig, known: string[]) => string[];
136
189
  /** What the app or its host answers to a method it does not have; the name is what it lacks. */
137
190
  export declare const lacking: (message: string) => string | undefined;
191
+ export declare const fileRequestsByTrace: (steps: StepReport[], spans: Map<string, {
192
+ index: number;
193
+ startedMs: number;
194
+ }>) => void;
138
195
  export declare const runCase: (c: Case, options: RunOptions) => Promise<CaseReport>;