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.
- package/lib/cases/coverage.d.ts +31 -0
- package/lib/cases/coverage.js +82 -0
- package/lib/cases/draft.d.ts +21 -0
- package/lib/cases/draft.js +53 -0
- package/lib/cases/fixtures.d.ts +11 -0
- package/lib/cases/fixtures.js +59 -8
- package/lib/cases/frames.d.ts +6 -0
- package/lib/cases/frames.js +28 -0
- package/lib/cases/nativeLogs.d.ts +14 -0
- package/lib/cases/nativeLogs.js +111 -0
- package/lib/cases/runDirs.d.ts +13 -0
- package/lib/cases/runDirs.js +48 -0
- package/lib/cases/runfile.d.ts +5 -1
- package/lib/cases/runfile.js +67 -4
- package/lib/cases/runner.d.ts +58 -1
- package/lib/cases/runner.js +322 -39
- package/lib/cases/templates.d.ts +2 -2
- package/lib/cases/templates.js +20 -2
- package/lib/cases/traces.js +4 -1
- package/lib/cli.js +27 -4
- package/lib/commands/cases.d.ts +8 -0
- package/lib/commands/cases.js +154 -25
- package/lib/commands/ci.d.ts +13 -0
- package/lib/commands/ci.js +184 -0
- package/lib/commands/coverage.d.ts +1 -0
- package/lib/commands/coverage.js +151 -0
- package/lib/commands/device.js +32 -2
- package/lib/commands/doctor.js +8 -2
- package/lib/commands/drive.js +3 -1
- package/lib/commands/fixtures.d.ts +7 -0
- package/lib/commands/fixtures.js +94 -0
- package/lib/commands/headless.d.ts +11 -0
- package/lib/commands/headless.js +189 -9
- package/lib/commands/init.d.ts +23 -0
- package/lib/commands/init.js +231 -26
- package/lib/commands/map.d.ts +2 -0
- package/lib/commands/map.js +18 -14
- package/lib/commands/os.js +43 -10
- package/lib/commands/report.d.ts +4 -0
- package/lib/commands/report.js +156 -20
- package/lib/config.d.ts +41 -0
- package/lib/config.js +30 -0
- package/lib/device/agentVersion.d.ts +7 -0
- package/lib/device/agentVersion.js +14 -0
- package/lib/device/devices.js +6 -0
- package/lib/device/driver.js +2 -2
- package/lib/knowledge/analyticsTraffic.d.ts +10 -0
- package/lib/knowledge/analyticsTraffic.js +109 -0
- package/lib/knowledge/errors.js +6 -0
- package/lib/knowledge/events.d.ts +2 -0
- package/lib/knowledge/why.d.ts +2 -0
- package/lib/knowledge/why.js +1 -1
- package/lib/mcp/server.js +45 -4
- package/lib/runtime.d.ts +9 -1
- package/lib/runtime.js +25 -3
- package/package.json +7 -4
- package/src/cases/coverage.ts +99 -0
- package/src/cases/draft.ts +75 -0
- package/src/cases/fixtures.ts +57 -8
- package/src/cases/frames.ts +30 -0
- package/src/cases/nativeLogs.ts +112 -0
- package/src/cases/runDirs.ts +48 -0
- package/src/cases/runfile.ts +64 -4
- package/src/cases/runner.ts +372 -42
- package/src/cases/templates.ts +20 -2
- package/src/cases/traces.ts +3 -1
- package/src/cli.ts +27 -4
- package/src/commands/cases.ts +165 -23
- package/src/commands/ci.ts +189 -0
- package/src/commands/coverage.ts +147 -0
- package/src/commands/device.ts +27 -2
- package/src/commands/doctor.ts +9 -2
- package/src/commands/drive.ts +3 -1
- package/src/commands/fixtures.ts +102 -0
- package/src/commands/headless.ts +197 -9
- package/src/commands/init.ts +270 -26
- package/src/commands/map.ts +20 -15
- package/src/commands/os.ts +38 -10
- package/src/commands/report.ts +162 -18
- package/src/config.ts +48 -0
- package/src/device/agentVersion.ts +17 -0
- package/src/device/devices.ts +5 -0
- package/src/device/driver.ts +2 -2
- package/src/knowledge/analyticsTraffic.ts +142 -0
- package/src/knowledge/errors.ts +6 -0
- package/src/knowledge/events.ts +2 -1
- package/src/knowledge/why.ts +3 -1
- package/src/mcp/server.ts +45 -4
- package/src/runtime.ts +24 -3
package/lib/commands/map.js
CHANGED
|
@@ -25,6 +25,23 @@ const describeAffected = (affected, chain) => {
|
|
|
25
25
|
}
|
|
26
26
|
return lines;
|
|
27
27
|
};
|
|
28
|
+
/** Source files a branch changed against `base`, committed or not. */
|
|
29
|
+
export const changedSources = (root, base, err) => {
|
|
30
|
+
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
31
|
+
try {
|
|
32
|
+
const names = `${git('diff', '--name-only', `${base}...HEAD`)}\n${git('diff', '--name-only', 'HEAD')}`;
|
|
33
|
+
return [
|
|
34
|
+
...new Set(names
|
|
35
|
+
.split('\n')
|
|
36
|
+
.map((l) => l.trim())
|
|
37
|
+
.filter((l) => /\.[cm]?[jt]sx?$/.test(l))),
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
err(`could not diff against ${base}: ${error instanceof Error ? error.message : String(error)}`);
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
};
|
|
28
45
|
export const mapCommands = [
|
|
29
46
|
define({
|
|
30
47
|
name: 'map build',
|
|
@@ -214,20 +231,7 @@ export const mapCommands = [
|
|
|
214
231
|
run: async (ctx) => {
|
|
215
232
|
const graph = ctx.graph();
|
|
216
233
|
const base = ctx.flags.base;
|
|
217
|
-
const
|
|
218
|
-
let changed = [];
|
|
219
|
-
try {
|
|
220
|
-
const names = `${git('diff', '--name-only', `${base}...HEAD`)}\n${git('diff', '--name-only', 'HEAD')}`;
|
|
221
|
-
changed = [
|
|
222
|
-
...new Set(names
|
|
223
|
-
.split('\n')
|
|
224
|
-
.map((l) => l.trim())
|
|
225
|
-
.filter((l) => /\.[cm]?[jt]sx?$/.test(l))),
|
|
226
|
-
];
|
|
227
|
-
}
|
|
228
|
-
catch (error) {
|
|
229
|
-
ctx.err(`could not diff against ${base}: ${error instanceof Error ? error.message : String(error)}`);
|
|
230
|
-
}
|
|
234
|
+
const changed = changedSources(ctx.root, base, ctx.err);
|
|
231
235
|
const affected = screensAffectedBy(graph, changed, ctx.flags.depth);
|
|
232
236
|
const lines = [`changed against ${base}: ${changed.length} source file(s)`, ...describeAffected(affected, true)];
|
|
233
237
|
if (!affected.length && changed.length)
|
package/lib/commands/os.js
CHANGED
|
@@ -30,11 +30,27 @@ export const osCommands = [
|
|
|
30
30
|
define({
|
|
31
31
|
name: 'os appstate',
|
|
32
32
|
group: 'os',
|
|
33
|
-
summary: '
|
|
34
|
-
positionals: [{ name: 'state', help: 'active | background | inactive', required: true }],
|
|
33
|
+
summary: 'AppState listeners see the change (background, then active: what refetches or re-locks); a host answers it, a simulator or an emulator does it for real',
|
|
34
|
+
positionals: [{ name: 'state', help: 'active | background | inactive (inactive: a host only)', required: true }],
|
|
35
|
+
flags: { app: { type: 'string', help: 'the app to bring back on a simulator or emulator (default: the config’s app)', value: 'bundleId' } },
|
|
35
36
|
aliases: ['appstate'],
|
|
36
37
|
needs: 'app',
|
|
37
|
-
run:
|
|
38
|
+
run: async (ctx) => {
|
|
39
|
+
const device = await ctx.device();
|
|
40
|
+
try {
|
|
41
|
+
return { json: await device.command('appstate', { state: ctx.args[0] }) };
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
// A host stands in for the OS; on a simulator or an emulator the OS itself moves the app.
|
|
45
|
+
if ((await device.resolved()).id === 'headless')
|
|
46
|
+
throw error;
|
|
47
|
+
const done = await simulator('appstate')(ctx);
|
|
48
|
+
// Back in front, the app dials in again; what comes next reads its screen.
|
|
49
|
+
if (ctx.args[0] === 'active')
|
|
50
|
+
await device.server.waitForApp(15_000);
|
|
51
|
+
return done;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
38
54
|
}),
|
|
39
55
|
define({
|
|
40
56
|
name: 'os network',
|
|
@@ -61,7 +77,7 @@ export const osCommands = [
|
|
|
61
77
|
define({
|
|
62
78
|
name: 'os biometrics',
|
|
63
79
|
group: 'os',
|
|
64
|
-
summary: '
|
|
80
|
+
summary: 'The sensor and what the next prompts answer; on a simulator, --available enrolls Face ID or Touch ID or not',
|
|
65
81
|
flags: {
|
|
66
82
|
available: { type: 'string', help: 'FaceID | TouchID | none', value: 'kind' },
|
|
67
83
|
next: { type: 'string', help: 'outcomes of the next prompts, in order', value: 'success,fail,cancel' },
|
|
@@ -69,11 +85,28 @@ export const osCommands = [
|
|
|
69
85
|
},
|
|
70
86
|
aliases: ['biometrics'],
|
|
71
87
|
needs: 'app',
|
|
72
|
-
run:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
run: async (ctx) => {
|
|
89
|
+
const device = await ctx.device();
|
|
90
|
+
const params = {
|
|
91
|
+
available: ctx.flags.available || undefined,
|
|
92
|
+
next: ctx.flags.next ? ctx.flags.next.split(',') : undefined,
|
|
93
|
+
fallback: ctx.flags.fallback || undefined,
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
return { json: await device.command('biometrics', params) };
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const info = await device.resolved();
|
|
100
|
+
if (info.id === 'headless')
|
|
101
|
+
throw error;
|
|
102
|
+
// A device's prompts answer the person in front of it: only a simulator's enrollment is set from outside.
|
|
103
|
+
// Anything else stays the host's, and the runner says the step cannot run here.
|
|
104
|
+
if (info.platform !== 'ios' || !params.available || params.next || params.fallback)
|
|
105
|
+
throw error;
|
|
106
|
+
ctx.args = [String(params.available)];
|
|
107
|
+
return simulator('biometrics')(ctx);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
77
110
|
}),
|
|
78
111
|
define({
|
|
79
112
|
name: 'os scan',
|
|
@@ -102,7 +135,7 @@ export const osCommands = [
|
|
|
102
135
|
define({
|
|
103
136
|
name: 'mock',
|
|
104
137
|
group: 'os',
|
|
105
|
-
summary: '
|
|
138
|
+
summary: 'Answer a request before the network: a host stands in for it, on a device the agent answers in the app (--sticky survives a host reset)',
|
|
106
139
|
positionals: [{ name: 'route', help: "'GET /path' (a bare path matches any method)" }],
|
|
107
140
|
flags: {
|
|
108
141
|
status: { type: 'number', help: 'HTTP status to answer', default: 200, value: 'code' },
|
package/lib/commands/report.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export type Pushed = {
|
|
|
10
10
|
};
|
|
11
11
|
/** What a run changed against the one before, in a line or two for a terminal or a pull request comment. */
|
|
12
12
|
export declare const describeChanges: (changes: Pushed["changes"]) => string[];
|
|
13
|
+
/** An uncompressed tar: frames are PNG or AVIF, already compressed, and a tar is read as it arrives. */
|
|
14
|
+
export declare const tarOf: (entries: [string, Uint8Array][]) => Uint8Array;
|
|
13
15
|
/**
|
|
14
16
|
* Uploads a run directory (`case run --out`) to the reports service: the run, its cases, then every frame and
|
|
15
17
|
* film the cases name. A file the organization's plan does not keep (a film on Free) is left out and listed.
|
|
@@ -19,6 +21,8 @@ export declare const pushRun: (dir: string, o: {
|
|
|
19
21
|
key: string;
|
|
20
22
|
project: string;
|
|
21
23
|
fetch?: Fetch;
|
|
24
|
+
onReady?: (url: string) => void;
|
|
25
|
+
onProgress?: (line: string) => void;
|
|
22
26
|
}) => Promise<Pushed>;
|
|
23
27
|
type Service = {
|
|
24
28
|
url: string;
|
package/lib/commands/report.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { define } from '../registry.js';
|
|
4
|
+
import { ageOf, megabytes, pruneRuns } from '../cases/runDirs.js';
|
|
5
|
+
import { keptOrDeleted } from './cases.js';
|
|
4
6
|
/** What a run changed against the one before, in a line or two for a terminal or a pull request comment. */
|
|
5
7
|
export const describeChanges = (changes) => changes
|
|
6
8
|
? [
|
|
@@ -14,6 +16,41 @@ const answer = async (response, doing) => {
|
|
|
14
16
|
const body = (await response.json().catch(() => ({})));
|
|
15
17
|
throw new Error(`${doing}: ${body.error ?? `the service answered ${response.status}`}`);
|
|
16
18
|
};
|
|
19
|
+
const UPLOADS_AT_ONCE = 4;
|
|
20
|
+
const CASES_PART = 256 * 1024;
|
|
21
|
+
const UPLOAD_ATTEMPTS = 3;
|
|
22
|
+
// A ustar header holds a name of up to 100 bytes and a folder of up to 155 before it.
|
|
23
|
+
const fitsTar = (file) => {
|
|
24
|
+
const cut = file.lastIndexOf('/');
|
|
25
|
+
return Buffer.byteLength(file.slice(cut + 1)) <= 100 && Buffer.byteLength(cut > 0 ? file.slice(0, cut) : '') <= 155;
|
|
26
|
+
};
|
|
27
|
+
/** An uncompressed tar: frames are PNG or AVIF, already compressed, and a tar is read as it arrives. */
|
|
28
|
+
export const tarOf = (entries) => {
|
|
29
|
+
const blocks = [];
|
|
30
|
+
for (const [file, data] of entries) {
|
|
31
|
+
const header = Buffer.alloc(512);
|
|
32
|
+
const cut = file.lastIndexOf('/');
|
|
33
|
+
header.write(file.slice(cut + 1), 0, 'utf8');
|
|
34
|
+
header.write('0000644\0', 100);
|
|
35
|
+
header.write('0000000\0', 108);
|
|
36
|
+
header.write('0000000\0', 116);
|
|
37
|
+
header.write(`${data.length.toString(8).padStart(11, '0')}\0`, 124);
|
|
38
|
+
header.write(`${Math.floor(Date.now() / 1000)
|
|
39
|
+
.toString(8)
|
|
40
|
+
.padStart(11, '0')}\0`, 136);
|
|
41
|
+
header.write('0', 156);
|
|
42
|
+
header.write('ustar\0', 257);
|
|
43
|
+
header.write('00', 263);
|
|
44
|
+
if (cut > 0)
|
|
45
|
+
header.write(file.slice(0, cut), 345, 'utf8');
|
|
46
|
+
header.fill(' ', 148, 156);
|
|
47
|
+
const sum = header.reduce((n, b) => n + b, 0);
|
|
48
|
+
header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148);
|
|
49
|
+
blocks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512));
|
|
50
|
+
}
|
|
51
|
+
blocks.push(Buffer.alloc(1024));
|
|
52
|
+
return Buffer.concat(blocks);
|
|
53
|
+
};
|
|
17
54
|
/**
|
|
18
55
|
* Uploads a run directory (`case run --out`) to the reports service: the run, its cases, then every frame and
|
|
19
56
|
* film the cases name. A file the organization's plan does not keep (a film on Free) is left out and listed.
|
|
@@ -30,35 +67,100 @@ export const pushRun = async (dir, o) => {
|
|
|
30
67
|
body: JSON.stringify({ project: o.project, run }),
|
|
31
68
|
}), 'creating the run');
|
|
32
69
|
const id = String(created.id);
|
|
33
|
-
|
|
34
|
-
const
|
|
70
|
+
// A dropped connection, a 5xx or a 429 is tried again, waiting longer each time.
|
|
71
|
+
const sendWithRetries = async (target, body, doing) => {
|
|
72
|
+
for (let attempt = 1;; attempt++) {
|
|
73
|
+
const response = await call(target, { method: 'PUT', headers: auth, body }).catch((error) => {
|
|
74
|
+
if (attempt >= UPLOAD_ATTEMPTS)
|
|
75
|
+
throw new Error(`${doing}: ${error instanceof Error ? error.message : String(error)}`);
|
|
76
|
+
return undefined;
|
|
77
|
+
});
|
|
78
|
+
if (response && ((response.status < 500 && response.status !== 429) || attempt >= UPLOAD_ATTEMPTS))
|
|
79
|
+
return response;
|
|
80
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
// The run file in parts of about 256 KB: with every request's headers and bodies it runs to megabytes, more
|
|
84
|
+
// than the service reads in one request. A service from before parts answers without `part`, and then
|
|
85
|
+
// takes the whole file in one.
|
|
86
|
+
const parts = [];
|
|
87
|
+
for (const line of cases.split('\n').filter((l) => l.trim())) {
|
|
88
|
+
const last = parts.length - 1;
|
|
89
|
+
if (last >= 0 && Buffer.byteLength(parts[last]) + Buffer.byteLength(line) < CASES_PART)
|
|
90
|
+
parts[last] += `\n${line}`;
|
|
91
|
+
else
|
|
92
|
+
parts.push(line);
|
|
93
|
+
}
|
|
94
|
+
for (const [n, part] of parts.entries()) {
|
|
95
|
+
const sent = await answer(await sendWithRetries(`${base}/v1/runs/${id}/cases?part=${n}`, Buffer.from(part), 'sending its cases'), 'sending its cases');
|
|
96
|
+
if (sent.part === undefined) {
|
|
97
|
+
await answer(await call(`${base}/v1/runs/${id}/cases`, { method: 'PUT', headers: auth, body: cases }), 'sending its cases');
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// The report opens as soon as its cases are in: their steps, results and requests show at once, and each
|
|
102
|
+
// frame as it arrives.
|
|
103
|
+
const finished = await answer(await call(`${base}/v1/runs/${id}/finish`, { method: 'POST', headers: auth }), 'finishing the run');
|
|
104
|
+
o.onReady?.(String(finished.url));
|
|
105
|
+
// Per case: its frames, sent as one tar, and its film, sent on its own (a film can be bigger than a request).
|
|
106
|
+
const work = [];
|
|
35
107
|
for (const line of cases.split('\n')) {
|
|
36
108
|
if (!line.trim())
|
|
37
109
|
continue;
|
|
38
110
|
const c = JSON.parse(line);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
111
|
+
const frames = (c.steps ?? []).map((s) => s.screenshot).filter((p) => !!p && fs.existsSync(path.join(dir, p)));
|
|
112
|
+
const film = c.video && fs.existsSync(path.join(dir, c.video)) ? c.video : undefined;
|
|
113
|
+
work.push({ frames: [...new Set(frames)], ...(film ? { film } : {}) });
|
|
42
114
|
}
|
|
43
115
|
let files = 0;
|
|
44
116
|
const refused = [];
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
continue;
|
|
49
|
-
const response = await call(`${base}/v1/runs/${id}/files/${file.split('/').map(encodeURIComponent).join('/')}`, {
|
|
50
|
-
method: 'PUT',
|
|
51
|
-
headers: auth,
|
|
52
|
-
body: fs.readFileSync(local),
|
|
53
|
-
});
|
|
117
|
+
const sendFile = async (file) => {
|
|
118
|
+
const target = `${base}/v1/runs/${id}/files/${file.split('/').map(encodeURIComponent).join('/')}`;
|
|
119
|
+
const response = await sendWithRetries(target, fs.readFileSync(path.join(dir, file)), `sending ${file}`);
|
|
54
120
|
if (response.status === 402)
|
|
55
121
|
refused.push(file);
|
|
56
122
|
else {
|
|
57
123
|
await answer(response, `sending ${file}`);
|
|
58
124
|
files += 1;
|
|
59
125
|
}
|
|
60
|
-
}
|
|
61
|
-
|
|
126
|
+
};
|
|
127
|
+
// A suite with screenshots is thousands of frames: a request per frame took one over 20 minutes to send. A
|
|
128
|
+
// service from before bundles answers 404, and the frames then go one by one, as they used to.
|
|
129
|
+
let bundles = true;
|
|
130
|
+
const sendFrames = async (frames) => {
|
|
131
|
+
const fits = frames.filter(fitsTar);
|
|
132
|
+
if (bundles && fits.length > 0) {
|
|
133
|
+
const response = await sendWithRetries(`${base}/v1/runs/${id}/bundle`, tarOf(fits.map((f) => [f, fs.readFileSync(path.join(dir, f))])), `sending ${fits.length} frames`);
|
|
134
|
+
if (response.status === 404 || response.status === 405)
|
|
135
|
+
bundles = false;
|
|
136
|
+
else {
|
|
137
|
+
await answer(response, `sending ${fits.length} frames`);
|
|
138
|
+
files += fits.length;
|
|
139
|
+
for (const f of frames.filter((f) => !fitsTar(f)))
|
|
140
|
+
await sendFile(f);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const f of frames)
|
|
145
|
+
await sendFile(f);
|
|
146
|
+
};
|
|
147
|
+
const queue = [...work];
|
|
148
|
+
const total = work.length;
|
|
149
|
+
let sent = 0;
|
|
150
|
+
let bytes = 0;
|
|
151
|
+
const started = Date.now();
|
|
152
|
+
const sizeOf = (c) => [...c.frames, ...(c.film ? [c.film] : [])].reduce((n, f) => n + fs.statSync(path.join(dir, f)).size, 0);
|
|
153
|
+
await Promise.all(Array.from({ length: Math.min(UPLOADS_AT_ONCE, queue.length) }, async () => {
|
|
154
|
+
for (let c = queue.shift(); c !== undefined; c = queue.shift()) {
|
|
155
|
+
await sendFrames(c.frames);
|
|
156
|
+
if (c.film)
|
|
157
|
+
await sendFile(c.film);
|
|
158
|
+
sent += 1;
|
|
159
|
+
bytes += sizeOf(c);
|
|
160
|
+
if (sent === total || sent % Math.max(1, Math.ceil(total / 10)) === 0)
|
|
161
|
+
o.onProgress?.(`frames: ${sent}/${total} cases, ${(bytes / 1024 / 1024).toFixed(1)} MB in ${((Date.now() - started) / 1000).toFixed(0)} s`);
|
|
162
|
+
}
|
|
163
|
+
}));
|
|
62
164
|
const changes = finished.changes;
|
|
63
165
|
return { url: String(finished.url), files, refused, ...(changes ? { changes } : {}) };
|
|
64
166
|
};
|
|
@@ -75,11 +177,41 @@ export const plan = async (o) => {
|
|
|
75
177
|
return { ...now, url: String((await ask(o, 'POST', route, o.go === 'upgrade' ? 'opening checkout' : 'opening the billing portal')).url) };
|
|
76
178
|
};
|
|
77
179
|
export const service = () => {
|
|
78
|
-
const url = process.env.LYNKEUS_REPORTS_URL;
|
|
180
|
+
const url = process.env.LYNKEUS_REPORTS_URL || 'https://lynkeus.io';
|
|
79
181
|
const key = process.env.LYNKEUS_REPORTS_KEY;
|
|
80
|
-
return
|
|
182
|
+
return key ? { url, key } : 'set LYNKEUS_REPORTS_KEY to an API key of your organization (lynkeus.io, API keys); LYNKEUS_REPORTS_URL names another service';
|
|
81
183
|
};
|
|
82
184
|
export const reportCommands = [
|
|
185
|
+
define({
|
|
186
|
+
name: 'report prune',
|
|
187
|
+
group: 'runs',
|
|
188
|
+
summary: 'Delete the runs `case run --out` left in a folder that ended more than a while ago',
|
|
189
|
+
details: 'Looks at the folder given, or each folder right under it, and deletes the ones that hold a run (`run.json` and `cases.jsonl`) older than --older. Nothing else is touched. Pushed reports stay on the service; this only frees the disk.',
|
|
190
|
+
positionals: [{ name: 'dir', help: 'a run folder, or the folder that holds them' }],
|
|
191
|
+
flags: {
|
|
192
|
+
older: { type: 'string', help: 'how old a run must be: 7d, 12h, 30m', value: 'age', default: '7d' },
|
|
193
|
+
'dry-run': { type: 'boolean', help: 'list what would go, delete nothing' },
|
|
194
|
+
},
|
|
195
|
+
needs: 'nothing',
|
|
196
|
+
session: false,
|
|
197
|
+
mcp: false,
|
|
198
|
+
run: async (ctx) => {
|
|
199
|
+
const root = path.resolve(ctx.args[0] ?? '.');
|
|
200
|
+
if (!fs.existsSync(root))
|
|
201
|
+
return { text: `${ctx.args[0]}: no such folder`, code: 2 };
|
|
202
|
+
const older = ageOf(String(ctx.flags.older ?? '7d'));
|
|
203
|
+
if (older === undefined)
|
|
204
|
+
return { text: `--older ${ctx.flags.older}: give it as 7d, 12h or 30m`, code: 2 };
|
|
205
|
+
const dry = !!ctx.flags['dry-run'];
|
|
206
|
+
const { removed, bytes } = pruneRuns(root, older, dry);
|
|
207
|
+
if (removed.length === 0)
|
|
208
|
+
return { text: 'no run old enough to delete', json: { removed, bytes } };
|
|
209
|
+
return {
|
|
210
|
+
text: [...removed.map((d) => `${dry ? 'would delete' : 'deleted'} ${d}`), `${dry ? 'would free' : 'freed'} ${megabytes(bytes)}`].join('\n'),
|
|
211
|
+
json: { removed, bytes },
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
83
215
|
define({
|
|
84
216
|
name: 'report push',
|
|
85
217
|
group: 'runs',
|
|
@@ -101,8 +233,12 @@ export const reportCommands = [
|
|
|
101
233
|
return { text: to, code: 2 };
|
|
102
234
|
const project = typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root);
|
|
103
235
|
try {
|
|
104
|
-
const pushed = await pushRun(dir, { ...to, project });
|
|
105
|
-
const notes = [
|
|
236
|
+
const pushed = await pushRun(dir, { ...to, project, onReady: (url) => ctx.err(`report: ${url} (its frames are on their way)`), onProgress: ctx.err });
|
|
237
|
+
const notes = [
|
|
238
|
+
...describeChanges(pushed.changes),
|
|
239
|
+
...(pushed.refused.length ? [`not kept on this plan: ${pushed.refused.join(', ')}`] : []),
|
|
240
|
+
keptOrDeleted(dir, ctx.config.report?.afterPush === 'delete' ? 'delete' : 'keep', pushed.refused.length === 0),
|
|
241
|
+
];
|
|
106
242
|
return { text: [pushed.url, ...notes].join('\n'), json: pushed };
|
|
107
243
|
}
|
|
108
244
|
catch (error) {
|
package/lib/config.d.ts
CHANGED
|
@@ -5,6 +5,17 @@ export declare const graphFile: (root?: string) => string;
|
|
|
5
5
|
export declare const edgesFile: (root?: string) => string;
|
|
6
6
|
export declare const frontierFile: (root?: string) => string;
|
|
7
7
|
export declare const runsDir: (root?: string) => string;
|
|
8
|
+
export declare const tokenFile: (root?: string) => string;
|
|
9
|
+
/**
|
|
10
|
+
* The secret the app and its driver share. LYNKEUS_TOKEN wins; otherwise it is the one `lynkeus init` wrote to
|
|
11
|
+
* `.lynkeus/token`, which the app's bundle reads too, so nobody has to set a variable in two terminals.
|
|
12
|
+
*/
|
|
13
|
+
export declare const readToken: (root?: string) => string | undefined;
|
|
14
|
+
/** Makes the project's token if it has none, and keeps it out of git: `.lynkeus` also holds what a team commits. */
|
|
15
|
+
export declare const ensureToken: (root?: string) => {
|
|
16
|
+
token: string;
|
|
17
|
+
made: boolean;
|
|
18
|
+
};
|
|
8
19
|
export declare const loadGraph: (root?: string) => ScreenGraph | null;
|
|
9
20
|
/**
|
|
10
21
|
* `lynkeus.config.json` in the app's folder: what would otherwise be repeated
|
|
@@ -13,16 +24,27 @@ export declare const loadGraph: (root?: string) => ScreenGraph | null;
|
|
|
13
24
|
*/
|
|
14
25
|
export type LynkeusConfig = {
|
|
15
26
|
cases?: CasesConfig;
|
|
27
|
+
/** What `lynkeus ci` brings up around a suite: the project's backend and the app's hosts. */
|
|
28
|
+
ci?: CiConfig;
|
|
16
29
|
/** The headless entry (default `qa/headless.tsx`). */
|
|
17
30
|
entry?: string;
|
|
18
31
|
/** The environment variable the app reads its API base from; the headless entry refuses one that is not local. */
|
|
19
32
|
apiEnv?: string;
|
|
33
|
+
/**
|
|
34
|
+
* The local backend `headless start` hands the app under `apiEnv` when the environment sets none: an agent
|
|
35
|
+
* starting the host over MCP cannot export a variable.
|
|
36
|
+
*/
|
|
37
|
+
apiUrl?: string;
|
|
20
38
|
/** The driver port the app dials. */
|
|
21
39
|
port?: number;
|
|
22
40
|
/** Simulator udid or name. */
|
|
23
41
|
device?: string;
|
|
24
42
|
/** Bundle id or application id. */
|
|
25
43
|
app?: string;
|
|
44
|
+
/** What a pushed run's local folder becomes: kept (the default), or deleted once the service has every file. */
|
|
45
|
+
report?: {
|
|
46
|
+
afterPush?: 'keep' | 'delete';
|
|
47
|
+
};
|
|
26
48
|
/**
|
|
27
49
|
* The language this app speaks, passed on and never interpreted: `os
|
|
28
50
|
* language` defaults to it, the headless entry mocks this locale, and an MCP
|
|
@@ -75,6 +97,25 @@ export type LynkeusConfig = {
|
|
|
75
97
|
export declare const configFile: (root?: string) => string;
|
|
76
98
|
/** The app's configuration, under the one `LYNKEUS_CONFIG` names: cases and fixtures often live in a repository of their own. */
|
|
77
99
|
export declare const loadConfig: (root?: string) => LynkeusConfig;
|
|
100
|
+
export type CiConfig = {
|
|
101
|
+
/** Shell commands that bring the project's backend up and down, and a URL that answers once it can serve. */
|
|
102
|
+
backend?: {
|
|
103
|
+
up: string;
|
|
104
|
+
down?: string;
|
|
105
|
+
ready?: string;
|
|
106
|
+
timeoutMs?: number;
|
|
107
|
+
};
|
|
108
|
+
/** How many hosts of the app, and the command that starts one (`{port}` is where it dials). Default: headless. */
|
|
109
|
+
hosts?: {
|
|
110
|
+
count?: number;
|
|
111
|
+
command?: string;
|
|
112
|
+
timeoutMs?: number;
|
|
113
|
+
};
|
|
114
|
+
/** Upload the run (needs LYNKEUS_REPORTS_KEY). */
|
|
115
|
+
push?: boolean;
|
|
116
|
+
/** Extra `case run` flags, as on the command line. */
|
|
117
|
+
run?: string[];
|
|
118
|
+
};
|
|
78
119
|
export declare const portFromEnv: (config?: LynkeusConfig) => number | undefined;
|
|
79
120
|
/** A list of regular expressions from the configuration, as one that matches any of them. */
|
|
80
121
|
export declare const anyOf: (patterns: string[] | undefined) => RegExp | undefined;
|
package/lib/config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { readJson } from './files.js';
|
|
@@ -6,6 +7,35 @@ export const graphFile = (root) => path.join(lynkeusDir(root), 'graph.json');
|
|
|
6
7
|
export const edgesFile = (root) => path.join(lynkeusDir(root), 'edges.jsonl');
|
|
7
8
|
export const frontierFile = (root) => path.join(lynkeusDir(root), 'frontier.jsonl');
|
|
8
9
|
export const runsDir = (root) => path.join(lynkeusDir(root), 'runs');
|
|
10
|
+
export const tokenFile = (root) => path.join(lynkeusDir(root), 'token');
|
|
11
|
+
/**
|
|
12
|
+
* The secret the app and its driver share. LYNKEUS_TOKEN wins; otherwise it is the one `lynkeus init` wrote to
|
|
13
|
+
* `.lynkeus/token`, which the app's bundle reads too, so nobody has to set a variable in two terminals.
|
|
14
|
+
*/
|
|
15
|
+
export const readToken = (root) => {
|
|
16
|
+
if (process.env.LYNKEUS_TOKEN)
|
|
17
|
+
return process.env.LYNKEUS_TOKEN;
|
|
18
|
+
try {
|
|
19
|
+
return fs.readFileSync(tokenFile(root), 'utf8').trim() || undefined;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/** Makes the project's token if it has none, and keeps it out of git: `.lynkeus` also holds what a team commits. */
|
|
26
|
+
export const ensureToken = (root) => {
|
|
27
|
+
const had = readToken(root);
|
|
28
|
+
if (had)
|
|
29
|
+
return { token: had, made: false };
|
|
30
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
31
|
+
fs.mkdirSync(lynkeusDir(root), { recursive: true });
|
|
32
|
+
fs.writeFileSync(tokenFile(root), `${token}\n`, { mode: 0o600 });
|
|
33
|
+
const ignore = path.join(lynkeusDir(root), '.gitignore');
|
|
34
|
+
const ignored = fs.existsSync(ignore) ? fs.readFileSync(ignore, 'utf8') : '';
|
|
35
|
+
if (!ignored.split('\n').includes('token'))
|
|
36
|
+
fs.writeFileSync(ignore, `${ignored}${ignored && !ignored.endsWith('\n') ? '\n' : ''}token\n`);
|
|
37
|
+
return { token, made: true };
|
|
38
|
+
};
|
|
9
39
|
export const loadGraph = (root) => {
|
|
10
40
|
const file = graphFile(root);
|
|
11
41
|
return fs.existsSync(file) ? readJson(file) : null;
|
|
@@ -5,3 +5,10 @@ export declare const CLI_VERSION: string;
|
|
|
5
5
|
* nothing): what to tell the person running it, or nothing when the two match.
|
|
6
6
|
*/
|
|
7
7
|
export declare const agentMismatch: (hello: Pick<HelloEvent, "agentVersion">, cli?: string) => string | undefined;
|
|
8
|
+
/** Who said hello: the app and where it runs, enough to tell two apps dialing the same port apart. */
|
|
9
|
+
export declare const helloIdentity: (hello: Pick<HelloEvent, "platform" | "native" | "app">) => string;
|
|
10
|
+
/**
|
|
11
|
+
* The driver keeps the app that dialed in last. Two apps or hosts dialing the same port take turns
|
|
12
|
+
* without an error, each step going to whichever came last: say so the first time a different one comes in.
|
|
13
|
+
*/
|
|
14
|
+
export declare const appSwitched: (before: string | undefined, now: string, port: number) => string | undefined;
|
|
@@ -10,3 +10,17 @@ export const agentMismatch = (hello, cli = CLI_VERSION) => {
|
|
|
10
10
|
const agent = hello.agentVersion ? `lynkeus-agent ${hello.agentVersion}` : 'a lynkeus-agent too old to say its version';
|
|
11
11
|
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).`;
|
|
12
12
|
};
|
|
13
|
+
/** Who said hello: the app and where it runs, enough to tell two apps dialing the same port apart. */
|
|
14
|
+
export const helloIdentity = (hello) => {
|
|
15
|
+
const app = hello.app ?? {};
|
|
16
|
+
const name = typeof app.name === 'string' ? app.name : 'an app';
|
|
17
|
+
const runtime = typeof app.runtime === 'string' ? app.runtime : hello.native ? 'native' : 'hosted';
|
|
18
|
+
return `${name} (${hello.platform}, ${runtime})`;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The driver keeps the app that dialed in last. Two apps or hosts dialing the same port take turns
|
|
22
|
+
* without an error, each step going to whichever came last: say so the first time a different one comes in.
|
|
23
|
+
*/
|
|
24
|
+
export const appSwitched = (before, now, port) => before && before !== now
|
|
25
|
+
? `⚠️ 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.`
|
|
26
|
+
: undefined;
|
package/lib/device/devices.js
CHANGED
|
@@ -50,6 +50,12 @@ export const pickAgentDevice = async (wanted) => {
|
|
|
50
50
|
const devices = await bootedSimulators();
|
|
51
51
|
const id = wanted ?? process.env.LYNKEUS_DEVICE;
|
|
52
52
|
const device = id ? devices.find((d) => d.id === id || d.name.includes(id)) : devices[0];
|
|
53
|
+
if (!device && id) {
|
|
54
|
+
// An adb serial names an Android device; which platform the app is on is only known once it dials in.
|
|
55
|
+
const android = (await androidDevices()).find((d) => d.id === id);
|
|
56
|
+
if (android)
|
|
57
|
+
return android;
|
|
58
|
+
}
|
|
53
59
|
if (!device) {
|
|
54
60
|
// A headless host dials the driver itself: nothing to boot or attach to.
|
|
55
61
|
if (!id && devices.length === 0)
|
package/lib/device/driver.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* only launching, screenshots and the status bar go through simctl or adb.
|
|
5
5
|
*/
|
|
6
6
|
import { AgentServer } from 'lynkeus-client';
|
|
7
|
-
import { portFromEnv } from '../config.js';
|
|
7
|
+
import { portFromEnv, readToken } from '../config.js';
|
|
8
8
|
import { withoutHost } from '../knowledge/problems.js';
|
|
9
9
|
import { lifecycleFor } from './platform.js';
|
|
10
10
|
export class Device {
|
|
@@ -17,7 +17,7 @@ export class Device {
|
|
|
17
17
|
this.info = info;
|
|
18
18
|
this.#resolving = options.resolving;
|
|
19
19
|
// The app serves commands only to a driver that presents its token; without one, anything on the loopback could drive a dev build.
|
|
20
|
-
this.server = options.server ?? new AgentServer({ token:
|
|
20
|
+
this.server = options.server ?? new AgentServer({ token: readToken(), port: portFromEnv() });
|
|
21
21
|
this.#lifecycle = lifecycleFor(info.platform);
|
|
22
22
|
this.#appId = options.appId ?? null;
|
|
23
23
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RequestRecord } from 'lynkeus-protocol';
|
|
2
|
+
export type TrafficEvent = {
|
|
3
|
+
provider: string;
|
|
4
|
+
name: string;
|
|
5
|
+
props?: Record<string, unknown>;
|
|
6
|
+
};
|
|
7
|
+
/** The service a request went to, when it is one of the known ones. */
|
|
8
|
+
export declare const serviceOf: (url: string) => string | undefined;
|
|
9
|
+
/** What a step's requests told analytics services: each body's events, or one per call to a service it cannot read. */
|
|
10
|
+
export declare const trafficEvents: (requests: RequestRecord[]) => TrafficEvent[];
|