verikun 0.5.0 → 0.7.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/README.md +111 -4
- package/dist/agent/cost.js +21 -10
- package/dist/agent/engine.js +8 -8
- package/dist/agent/openai.js +243 -0
- package/dist/agent/remote.js +166 -0
- package/dist/args.js +2 -0
- package/dist/bin/verikun.js +0 -0
- package/dist/cli.js +381 -98
- package/dist/drivers/adb.js +12 -0
- package/dist/drivers/ios.js +7 -0
- package/dist/drivers/simctl.js +156 -0
- package/dist/report.js +93 -3
- package/dist/rpc.js +46 -0
- package/dist/run.js +101 -1
- package/dist/server.js +349 -0
- package/dist/suite.js +152 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SimctlDriver = void 0;
|
|
4
|
+
const node_os_1 = require("node:os");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const errors_1 = require("../errors");
|
|
8
|
+
const exec_1 = require("../exec");
|
|
9
|
+
const XCRUN = 'xcrun';
|
|
10
|
+
function listPhysicalDevices() {
|
|
11
|
+
const r = (0, exec_1.runText)(XCRUN, ['devicectl', 'list', 'devices']);
|
|
12
|
+
if (r.code !== 0)
|
|
13
|
+
return [];
|
|
14
|
+
const lines = r.stdout.split('\n');
|
|
15
|
+
const headerIdx = lines.findIndex((l) => l.includes('Identifier'));
|
|
16
|
+
if (headerIdx < 0)
|
|
17
|
+
return [];
|
|
18
|
+
const header = lines[headerIdx];
|
|
19
|
+
const nameCol = header.indexOf('Name');
|
|
20
|
+
const hostCol = header.indexOf('Hostname');
|
|
21
|
+
const idCol = header.indexOf('Identifier');
|
|
22
|
+
const stateCol = header.indexOf('State');
|
|
23
|
+
const modelCol = header.indexOf('Model');
|
|
24
|
+
const devices = [];
|
|
25
|
+
for (const line of lines.slice(headerIdx + 2)) {
|
|
26
|
+
if (!line.trim() || line.startsWith('-'))
|
|
27
|
+
continue;
|
|
28
|
+
const identifier = line.slice(idCol, stateCol).trim();
|
|
29
|
+
const state = line.slice(stateCol, modelCol).trim();
|
|
30
|
+
const name = line.slice(nameCol, hostCol).trim();
|
|
31
|
+
const model = line.slice(modelCol).trim();
|
|
32
|
+
if (!identifier)
|
|
33
|
+
continue;
|
|
34
|
+
const productMatch = model.match(/\(([^)]+)\)$/);
|
|
35
|
+
devices.push({
|
|
36
|
+
serial: identifier,
|
|
37
|
+
state,
|
|
38
|
+
model: name,
|
|
39
|
+
product: productMatch?.[1],
|
|
40
|
+
platform: 'ios',
|
|
41
|
+
note: 'physical — screenshot only, tap/ui not supported',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return devices;
|
|
45
|
+
}
|
|
46
|
+
// iOS support is intentionally partial. `simctl` covers screenshots, launch,
|
|
47
|
+
// and stop. Full interaction (tap, swipe, type) and the accessibility hierarchy
|
|
48
|
+
// require WebDriverAgent (https://github.com/appium/WebDriverAgent) — the
|
|
49
|
+
// planned next step. WDA is an open-source XCTest HTTP server: build it once
|
|
50
|
+
// in Xcode, sign it with your Apple developer certificate, and run it on the
|
|
51
|
+
// target device or simulator. No Python required; works on both simulators and
|
|
52
|
+
// physical devices. The Driver seam is already in place so the command layer
|
|
53
|
+
// won't change when WDA support is added.
|
|
54
|
+
function notSupported(feature) {
|
|
55
|
+
throw new errors_1.CliError(`iOS ${feature} is not supported yet.\n` +
|
|
56
|
+
'Screenshots, launch, and stop work today via xcrun simctl.\n' +
|
|
57
|
+
'Full interaction (tap / swipe / type) and UI hierarchy inspection are planned\n' +
|
|
58
|
+
'via WebDriverAgent — build it once in Xcode and vk will drive it over HTTP.\n' +
|
|
59
|
+
'See: https://github.com/appium/WebDriverAgent', 3);
|
|
60
|
+
}
|
|
61
|
+
class SimctlDriver {
|
|
62
|
+
platform = 'ios';
|
|
63
|
+
udid;
|
|
64
|
+
constructor(device) {
|
|
65
|
+
this.udid = device || 'booted';
|
|
66
|
+
}
|
|
67
|
+
listDevices() {
|
|
68
|
+
const devices = [];
|
|
69
|
+
// Simulators via simctl
|
|
70
|
+
const { stdout } = (0, exec_1.runText)(XCRUN, ['simctl', 'list', 'devices', 'available', '--json']);
|
|
71
|
+
try {
|
|
72
|
+
const data = JSON.parse(stdout);
|
|
73
|
+
for (const [runtime, list] of Object.entries(data.devices)) {
|
|
74
|
+
for (const d of list) {
|
|
75
|
+
devices.push({
|
|
76
|
+
serial: d.udid,
|
|
77
|
+
state: d.state.toLowerCase(),
|
|
78
|
+
model: d.name,
|
|
79
|
+
product: runtime.split('.').pop(),
|
|
80
|
+
platform: 'ios',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* tolerate unexpected simctl output */
|
|
87
|
+
}
|
|
88
|
+
// Physical devices via devicectl (Xcode 15+)
|
|
89
|
+
devices.push(...listPhysicalDevices());
|
|
90
|
+
return devices;
|
|
91
|
+
}
|
|
92
|
+
resolvedSerial() {
|
|
93
|
+
return this.udid;
|
|
94
|
+
}
|
|
95
|
+
screenshot() {
|
|
96
|
+
const tmp = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `verikun-ios-${process.pid}.png`);
|
|
97
|
+
const r = (0, exec_1.runText)(XCRUN, ['simctl', 'io', this.udid, 'screenshot', tmp]);
|
|
98
|
+
if (r.code !== 0)
|
|
99
|
+
throw new errors_1.CliError(`simctl screenshot failed: ${r.stderr.trim()}`, 3);
|
|
100
|
+
const buf = (0, node_fs_1.readFileSync)(tmp);
|
|
101
|
+
try {
|
|
102
|
+
(0, node_fs_1.unlinkSync)(tmp);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* best-effort cleanup */
|
|
106
|
+
}
|
|
107
|
+
return buf;
|
|
108
|
+
}
|
|
109
|
+
launch(appId) {
|
|
110
|
+
const r = (0, exec_1.runText)(XCRUN, ['simctl', 'launch', this.udid, appId]);
|
|
111
|
+
if (r.code !== 0)
|
|
112
|
+
throw new errors_1.CliError(`simctl launch failed: ${r.stderr.trim()}`, 3);
|
|
113
|
+
}
|
|
114
|
+
stop(appId) {
|
|
115
|
+
const r = (0, exec_1.runText)(XCRUN, ['simctl', 'terminate', this.udid, appId]);
|
|
116
|
+
if (r.code !== 0)
|
|
117
|
+
throw new errors_1.CliError(`simctl terminate failed: ${r.stderr.trim()}`, 3);
|
|
118
|
+
}
|
|
119
|
+
clearApp(appId) {
|
|
120
|
+
// simctl has no per-app data reset. The manual equivalent is to uninstall and
|
|
121
|
+
// reinstall, which also removes the app itself — so we don't do it implicitly.
|
|
122
|
+
throw new errors_1.CliError(`iOS app-data clearing is not supported yet (requested for '${appId}').\n` +
|
|
123
|
+
'simctl has no per-app data reset; uninstall + reinstall ' +
|
|
124
|
+
'(`xcrun simctl uninstall <udid> <bundleId>`) is the manual equivalent, but it removes the app too.', 3);
|
|
125
|
+
}
|
|
126
|
+
getElements() {
|
|
127
|
+
return notSupported('hierarchy inspection');
|
|
128
|
+
}
|
|
129
|
+
screenSize() {
|
|
130
|
+
return notSupported('screen size');
|
|
131
|
+
}
|
|
132
|
+
tap() {
|
|
133
|
+
notSupported('tap');
|
|
134
|
+
}
|
|
135
|
+
swipe() {
|
|
136
|
+
notSupported('swipe');
|
|
137
|
+
}
|
|
138
|
+
inputText() {
|
|
139
|
+
notSupported('text input');
|
|
140
|
+
}
|
|
141
|
+
pressKey() {
|
|
142
|
+
notSupported('key events');
|
|
143
|
+
}
|
|
144
|
+
currentApp() {
|
|
145
|
+
return notSupported('current app');
|
|
146
|
+
}
|
|
147
|
+
getLogs() {
|
|
148
|
+
// Eventually: `xcrun simctl spawn <udid> log show` (simulators) / devicectl
|
|
149
|
+
// (physical). Heavier (predicate language, large output) — deferred with WDA.
|
|
150
|
+
return notSupported('log capture');
|
|
151
|
+
}
|
|
152
|
+
deviceTime() {
|
|
153
|
+
return ''; // no log window to anchor while iOS log capture is unsupported
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
exports.SimctlDriver = SimctlDriver;
|
package/dist/report.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// Pure report rendering: a finished RunState in -> a JUnit XML / HTML string out
|
|
2
|
+
// Pure report rendering: a finished RunState in -> a JUnit XML / HTML string out,
|
|
3
|
+
// and a finished SuiteRun in -> the suite index.json / index.html out.
|
|
3
4
|
// No fs, no device, no side effects — so it is trivially testable and the run
|
|
4
|
-
// recorder (run.ts)
|
|
5
|
-
// the types only.
|
|
5
|
+
// recorder (run.ts) / suite runner (suite.ts) own all the I/O. The RunState data
|
|
6
|
+
// model lives in run.ts; we import the types only.
|
|
6
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
8
|
exports.toJUnitXml = toJUnitXml;
|
|
9
|
+
exports.suiteTotals = suiteTotals;
|
|
10
|
+
exports.toSuiteIndexJson = toSuiteIndexJson;
|
|
11
|
+
exports.toSuiteHtml = toSuiteHtml;
|
|
8
12
|
exports.toHtml = toHtml;
|
|
9
13
|
// --- escaping -------------------------------------------------------------
|
|
10
14
|
// XML 1.0 forbids most control chars even when escaped; drop them so a stray
|
|
@@ -180,6 +184,92 @@ function stepHtml(s) {
|
|
|
180
184
|
parts.push(`<details><summary>Device logs</summary><pre>${htmlEsc(s.logs)}</pre></details>`);
|
|
181
185
|
return `<li class="step ${s.status}">${parts.join('\n ')}</li>`;
|
|
182
186
|
}
|
|
187
|
+
/** Tally a suite's tests into its totals (pure; used by suite.ts and tests). */
|
|
188
|
+
function suiteTotals(tests) {
|
|
189
|
+
const round = (n) => Number(n.toFixed(4));
|
|
190
|
+
return {
|
|
191
|
+
tests: tests.length,
|
|
192
|
+
passed: tests.filter((t) => t.ok).length,
|
|
193
|
+
failed: tests.filter((t) => !t.ok).length,
|
|
194
|
+
steps: tests.reduce((a, t) => a + t.steps, 0),
|
|
195
|
+
costUsd: round(tests.reduce((a, t) => a + t.costUsd, 0)),
|
|
196
|
+
durationMs: tests.reduce((a, t) => a + t.durationMs, 0),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function toSuiteIndexJson(suite) {
|
|
200
|
+
return JSON.stringify(suite, null, 2) + '\n';
|
|
201
|
+
}
|
|
202
|
+
const SUITE_STYLE = `
|
|
203
|
+
table.tests { width:100%; border-collapse:collapse; background:#fff; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
|
|
204
|
+
table.tests th, table.tests td { text-align:left; padding:10px 12px; border-top:1px solid var(--line); font-size:13px; }
|
|
205
|
+
table.tests th { background:#eaeef2; color:var(--muted); border-top:none; font-size:12px; letter-spacing:.03em; text-transform:uppercase; }
|
|
206
|
+
table.tests td.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
|
207
|
+
table.tests a { color:inherit; }
|
|
208
|
+
.fail-reason { color:var(--fail); font-size:12px; margin-top:2px; }
|
|
209
|
+
`;
|
|
210
|
+
function suiteTestRow(t, linkBase) {
|
|
211
|
+
// A test that errored before its run started (id '') has no report to link.
|
|
212
|
+
const label = t.id
|
|
213
|
+
? `<a href="${htmlEsc(`${linkBase}runs/${encodeURIComponent(t.id)}/report.html`)}">${htmlEsc(t.name)}</a>`
|
|
214
|
+
: htmlEsc(t.name);
|
|
215
|
+
const failure = t.failure ? `<div class="fail-reason">${htmlEsc(t.failure)}</div>` : '';
|
|
216
|
+
return ` <tr>
|
|
217
|
+
<td><span class="st ${t.ok ? 'passed' : 'failed'}">${t.ok ? 'PASS' : 'FAIL'}</span></td>
|
|
218
|
+
<td>${label}${failure}</td>
|
|
219
|
+
<td class="num">${t.passedSteps}/${t.steps}${t.failedSteps ? ` (${t.failedSteps} failed)` : ''}</td>
|
|
220
|
+
<td class="num">${t.modelRepairs || ''}</td>
|
|
221
|
+
<td class="num">$${t.costUsd.toFixed(4)}</td>
|
|
222
|
+
<td class="num">${fmtDuration(t.durationMs)}</td>
|
|
223
|
+
</tr>`;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The suite overview page. `linkBase` is the relative path from index.html to the
|
|
227
|
+
* directory holding `runs/<id>/report.html` — '../../' when the suite lives at
|
|
228
|
+
* .verikun/suites/<id>/ and runs at .verikun/runs/<id>/ (the default layout).
|
|
229
|
+
*/
|
|
230
|
+
function toSuiteHtml(suite, opts = {}) {
|
|
231
|
+
const linkBase = opts.linkBase ?? '../../';
|
|
232
|
+
const t = suite.totals;
|
|
233
|
+
const chips = [
|
|
234
|
+
`<span class="chip pass">${t.passed} passed</span>`,
|
|
235
|
+
t.failed ? `<span class="chip fail">${t.failed} failed</span>` : '',
|
|
236
|
+
`<span class="chip muted">${t.tests} tests · ${t.steps} steps · ${fmtDuration(t.durationMs)} · $${t.costUsd.toFixed(4)}</span>`,
|
|
237
|
+
]
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
.join('\n ');
|
|
240
|
+
const metaBits = [
|
|
241
|
+
`<code>${htmlEsc(suite.id)}</code>`,
|
|
242
|
+
htmlEsc(suite.platform) + (suite.device ? ` · ${htmlEsc(suite.device)}` : ''),
|
|
243
|
+
`started ${htmlEsc(suite.startedAt)}`,
|
|
244
|
+
`finished ${htmlEsc(suite.finishedAt)}`,
|
|
245
|
+
`verikun ${htmlEsc(suite.verikun)}`,
|
|
246
|
+
].filter(Boolean);
|
|
247
|
+
return `<!doctype html>
|
|
248
|
+
<html lang="en">
|
|
249
|
+
<head>
|
|
250
|
+
<meta charset="utf-8">
|
|
251
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
252
|
+
<title>verikun suite — ${htmlEsc(suite.name)}</title>
|
|
253
|
+
<style>${STYLE}${SUITE_STYLE}</style>
|
|
254
|
+
</head>
|
|
255
|
+
<body>
|
|
256
|
+
<div class="wrap">
|
|
257
|
+
<h1>verikun test suite — ${htmlEsc(suite.name)}</h1>
|
|
258
|
+
<div class="meta">${metaBits.join(' · ')}</div>
|
|
259
|
+
<div class="summary">
|
|
260
|
+
${chips}
|
|
261
|
+
</div>
|
|
262
|
+
<table class="tests">
|
|
263
|
+
<thead><tr><th></th><th>Test</th><th>Steps</th><th>Repairs</th><th>Cost</th><th>Duration</th></tr></thead>
|
|
264
|
+
<tbody>
|
|
265
|
+
${suite.tests.map((x) => suiteTestRow(x, linkBase)).join('\n')}
|
|
266
|
+
</tbody>
|
|
267
|
+
</table>
|
|
268
|
+
</div>
|
|
269
|
+
</body>
|
|
270
|
+
</html>
|
|
271
|
+
`;
|
|
272
|
+
}
|
|
183
273
|
function toHtml(run) {
|
|
184
274
|
const c = counts(run);
|
|
185
275
|
const chips = [
|
package/dist/rpc.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The remote-execution wire contract shared by `vk server` (src/server.ts) and the
|
|
3
|
+
// remote backend (src/agent/remote.ts): request/response shapes plus the error codec
|
|
4
|
+
// that carries a thrown error's SUBCLASS IDENTITY across the wire. The `vk ai` engine
|
|
5
|
+
// decides heal-vs-terminal via `instanceof SelectorNotFoundError/AmbiguousSelectorError`
|
|
6
|
+
// (agent/engine.ts), so a serialized error must rebuild into the same class — a plain
|
|
7
|
+
// `{message}` would silently turn every remote selector miss into a terminal failure.
|
|
8
|
+
//
|
|
9
|
+
// Pure types + pure functions only: no http, no fetch, no fs — so both sides (and the
|
|
10
|
+
// unit tests) can import it without dragging in transport code.
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.describeError = describeError;
|
|
13
|
+
exports.rebuildError = rebuildError;
|
|
14
|
+
const errors_1 = require("./errors");
|
|
15
|
+
// --- error codec ------------------------------------------------------------
|
|
16
|
+
/** Serialize a thrown error for the wire, preserving what the engine needs to
|
|
17
|
+
* tell a heal trigger from a terminal failure. */
|
|
18
|
+
function describeError(e) {
|
|
19
|
+
if (e instanceof errors_1.AmbiguousSelectorError) {
|
|
20
|
+
return { kind: 'AmbiguousSelectorError', name: e.name, message: e.message, exitCode: e.exitCode, candidates: e.candidates };
|
|
21
|
+
}
|
|
22
|
+
if (e instanceof errors_1.SelectorNotFoundError) {
|
|
23
|
+
return { kind: 'SelectorNotFoundError', name: e.name, message: e.message, exitCode: e.exitCode };
|
|
24
|
+
}
|
|
25
|
+
if (e instanceof errors_1.CliError) {
|
|
26
|
+
return { kind: 'CliError', name: e.name, message: e.message, exitCode: e.exitCode };
|
|
27
|
+
}
|
|
28
|
+
return { kind: 'Error', name: e.name || 'Error', message: e.message, exitCode: 3 };
|
|
29
|
+
}
|
|
30
|
+
/** Rebuild the error a server serialized, restoring its class so `instanceof`
|
|
31
|
+
* checks (and `candidates` / `exitCode`) behave as if it were thrown locally. */
|
|
32
|
+
function rebuildError(d) {
|
|
33
|
+
switch (d.kind) {
|
|
34
|
+
case 'AmbiguousSelectorError':
|
|
35
|
+
return new errors_1.AmbiguousSelectorError(d.message, d.candidates ?? []);
|
|
36
|
+
case 'SelectorNotFoundError':
|
|
37
|
+
return new errors_1.SelectorNotFoundError(d.message);
|
|
38
|
+
case 'CliError':
|
|
39
|
+
return new errors_1.CliError(d.message, d.exitCode);
|
|
40
|
+
default: {
|
|
41
|
+
const e = new Error(d.message);
|
|
42
|
+
e.name = d.name || 'Error';
|
|
43
|
+
return e;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/run.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Recorder = void 0;
|
|
4
4
|
exports.isRecordable = isRecordable;
|
|
5
|
+
exports.runId = runId;
|
|
6
|
+
exports.uniqueDir = uniqueDir;
|
|
5
7
|
exports.rolloverReason = rolloverReason;
|
|
6
8
|
exports.stepName = stepName;
|
|
7
9
|
const node_fs_1 = require("node:fs");
|
|
@@ -54,11 +56,13 @@ function saveState(dir, state) {
|
|
|
54
56
|
function nowIso() {
|
|
55
57
|
return new Date().toISOString();
|
|
56
58
|
}
|
|
59
|
+
/** Timestamp id used for runs — exported so `vk suite` mints suite ids the same way. */
|
|
57
60
|
function runId() {
|
|
58
61
|
const d = new Date();
|
|
59
62
|
const p = (n) => String(n).padStart(2, '0');
|
|
60
63
|
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
61
64
|
}
|
|
65
|
+
/** First non-existing `<base>`, `<base>-2`, `<base>-3`, … — exported for `vk suite`. */
|
|
62
66
|
function uniqueDir(base) {
|
|
63
67
|
if (!(0, node_fs_1.existsSync)(base))
|
|
64
68
|
return base;
|
|
@@ -155,11 +159,17 @@ class Recorder {
|
|
|
155
159
|
dir;
|
|
156
160
|
step;
|
|
157
161
|
startMs;
|
|
158
|
-
|
|
162
|
+
sink;
|
|
163
|
+
constructor(state, dir, step, startMs,
|
|
164
|
+
/** Ephemeral mode (vk server): artifacts land here instead of on disk, and
|
|
165
|
+
* commit() never touches ./.verikun — the step is returned to the caller
|
|
166
|
+
* via takeEphemeral() and spliced into the CALLER's run instead. */
|
|
167
|
+
sink) {
|
|
159
168
|
this.state = state;
|
|
160
169
|
this.dir = dir;
|
|
161
170
|
this.step = step;
|
|
162
171
|
this.startMs = startMs;
|
|
172
|
+
this.sink = sink;
|
|
163
173
|
}
|
|
164
174
|
/**
|
|
165
175
|
* Open a step for a recordable command, auto-starting an implicit run if none
|
|
@@ -234,6 +244,43 @@ class Recorder {
|
|
|
234
244
|
};
|
|
235
245
|
return new Recorder(state, dir, step, Date.now());
|
|
236
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Open a single-step recorder that never touches disk — the `vk server` path.
|
|
249
|
+
* The step (and any artifacts, e.g. failure screenshots) accumulate in memory
|
|
250
|
+
* and are handed back by takeEphemeral() for the wire; the CALLING verikun
|
|
251
|
+
* splices them into its own on-disk run via appendForeignStep. Deliberately
|
|
252
|
+
* ignores VERIKUN_NO_RUN: this is how step detail reaches the remote caller,
|
|
253
|
+
* not a local test run.
|
|
254
|
+
*/
|
|
255
|
+
static beginEphemeralStep(command, positionals, flags, platform, serial) {
|
|
256
|
+
const state = {
|
|
257
|
+
id: 'ephemeral',
|
|
258
|
+
name: 'ephemeral',
|
|
259
|
+
startedAt: nowIso(),
|
|
260
|
+
updatedAt: nowIso(),
|
|
261
|
+
platform,
|
|
262
|
+
device: serial,
|
|
263
|
+
implicit: true,
|
|
264
|
+
steps: [],
|
|
265
|
+
};
|
|
266
|
+
const step = {
|
|
267
|
+
index: 0,
|
|
268
|
+
command,
|
|
269
|
+
name: stepName(command, positionals, flags),
|
|
270
|
+
startedAt: nowIso(),
|
|
271
|
+
durationMs: 0,
|
|
272
|
+
status: 'passed',
|
|
273
|
+
exitCode: 0,
|
|
274
|
+
};
|
|
275
|
+
// No dir: ephemeral recording never touches disk (activeDir() would mkdir
|
|
276
|
+
// ./.verikun as a side effect), and writeArtifact/commit divert to the sink.
|
|
277
|
+
return new Recorder(state, '', step, Date.now(), {});
|
|
278
|
+
}
|
|
279
|
+
/** The finished ephemeral step + its in-memory artifacts. Only meaningful after
|
|
280
|
+
* finish()/finishError() on a beginEphemeralStep recorder. */
|
|
281
|
+
takeEphemeral() {
|
|
282
|
+
return { step: this.step, artifacts: this.sink ?? {} };
|
|
283
|
+
}
|
|
237
284
|
/** Attach selector / heal-tier / resolved-element / message detail to the current step. */
|
|
238
285
|
note(info) {
|
|
239
286
|
const s = this.step;
|
|
@@ -319,11 +366,17 @@ class Recorder {
|
|
|
319
366
|
}
|
|
320
367
|
}
|
|
321
368
|
writeArtifact(rel, buf) {
|
|
369
|
+
if (this.sink) {
|
|
370
|
+
this.sink[rel] = buf;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
322
373
|
(0, node_fs_1.mkdirSync)((0, node_path_1.join)(this.dir, 'artifacts'), { recursive: true });
|
|
323
374
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(this.dir, rel), buf);
|
|
324
375
|
}
|
|
325
376
|
commit() {
|
|
326
377
|
this.step.durationMs = Date.now() - this.startMs;
|
|
378
|
+
if (this.sink)
|
|
379
|
+
return; // ephemeral: the step goes to the wire, not to disk
|
|
327
380
|
this.state.steps.push(this.step);
|
|
328
381
|
this.state.updatedAt = nowIso();
|
|
329
382
|
saveState(this.dir, this.state);
|
|
@@ -364,6 +417,53 @@ class Recorder {
|
|
|
364
417
|
Object.assign(state, patch);
|
|
365
418
|
saveState(dir, state);
|
|
366
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* Splice a step another verikun produced (a `vk server`'s ephemeral step) into
|
|
422
|
+
* the ACTIVE local run, so a remote `vk ai` run archives a report identical to
|
|
423
|
+
* a local one. Re-indexes the step, writes its artifact buffers under the new
|
|
424
|
+
* index, and rewrites the step's artifact references to match. Creates an
|
|
425
|
+
* implicit run first if none is active (parity with beginStep's auto-start).
|
|
426
|
+
*/
|
|
427
|
+
static appendForeignStep(step, artifacts = {}, ctx = {}) {
|
|
428
|
+
if (process.env.VERIKUN_NO_RUN)
|
|
429
|
+
return;
|
|
430
|
+
const dir = activeDir();
|
|
431
|
+
let state = loadState(dir);
|
|
432
|
+
if (!state) {
|
|
433
|
+
state = {
|
|
434
|
+
id: runId(),
|
|
435
|
+
name: 'run',
|
|
436
|
+
startedAt: nowIso(),
|
|
437
|
+
updatedAt: nowIso(),
|
|
438
|
+
platform: ctx.platform ?? 'android',
|
|
439
|
+
device: ctx.device,
|
|
440
|
+
session: currentSession(),
|
|
441
|
+
implicit: true,
|
|
442
|
+
steps: [],
|
|
443
|
+
};
|
|
444
|
+
(0, output_1.err)('[verikun] recording test run (implicit) — archive: `vk run archive` · discard: `vk run clear`');
|
|
445
|
+
}
|
|
446
|
+
const index = state.steps.length;
|
|
447
|
+
const spliced = { ...step, index };
|
|
448
|
+
for (const [rel, buf] of Object.entries(artifacts)) {
|
|
449
|
+
// The rel path comes from the (authenticated) server's response; still, never
|
|
450
|
+
// let it steer the write outside the run's artifacts/ directory.
|
|
451
|
+
if (!/^artifacts\/[A-Za-z0-9._-]+$/.test(rel)) {
|
|
452
|
+
(0, output_1.err)(`[verikun] skipping remote artifact with unexpected path '${rel}'`);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const newRel = rel.replace(/\bstep-\d+-/, `step-${index}-`);
|
|
456
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.join)(dir, 'artifacts'), { recursive: true });
|
|
457
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, newRel), buf);
|
|
458
|
+
if (spliced.image === rel)
|
|
459
|
+
spliced.image = newRel;
|
|
460
|
+
if (spliced.failImage === rel)
|
|
461
|
+
spliced.failImage = newRel;
|
|
462
|
+
}
|
|
463
|
+
state.steps.push(spliced);
|
|
464
|
+
state.updatedAt = nowIso();
|
|
465
|
+
saveState(dir, state);
|
|
466
|
+
}
|
|
367
467
|
/** Downgrade the most-recently-recorded step from a failed attempt to a healed
|
|
368
468
|
* pass — the `vk ai` engine calls this after a successful repair so a
|
|
369
469
|
* self-healed leaf does not count as a failure (and the run that the engine
|