verikun 0.4.1
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/LICENSE +21 -0
- package/README.md +435 -0
- package/dist/agent/cache.js +128 -0
- package/dist/agent/claude.js +144 -0
- package/dist/agent/cost.js +100 -0
- package/dist/agent/engine.js +205 -0
- package/dist/agent/grammar.js +80 -0
- package/dist/agent/ir.js +212 -0
- package/dist/agent/provider.js +2 -0
- package/dist/args.js +102 -0
- package/dist/bin/verikun.js +8 -0
- package/dist/cli.js +1298 -0
- package/dist/drivers/adb.js +300 -0
- package/dist/drivers/index.js +13 -0
- package/dist/drivers/simctl.js +156 -0
- package/dist/errors.js +51 -0
- package/dist/exec.js +42 -0
- package/dist/image.js +212 -0
- package/dist/output.js +43 -0
- package/dist/report.js +223 -0
- package/dist/run.js +434 -0
- package/dist/types.js +5 -0
- package/dist/ui/android-parse.js +149 -0
- package/dist/ui/format.js +71 -0
- package/dist/ui/selector.js +117 -0
- package/dist/version.js +6 -0
- package/package.json +53 -0
package/dist/image.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Hand-rolled, dependency-free PNG downscaler.
|
|
3
|
+
//
|
|
4
|
+
// A device screenshot is ~1080×2400; an agent reading that image pays for its
|
|
5
|
+
// pixel area in tokens. Downscaling to a smaller longest edge keeps UI text
|
|
6
|
+
// legible while cutting that cost markedly. We do it in pure Node — decode the
|
|
7
|
+
// PNG, box-average the pixels, re-encode — using only `node:zlib` (a builtin),
|
|
8
|
+
// honoring the project's zero-runtime-dependency rule.
|
|
9
|
+
//
|
|
10
|
+
// Scope: 8-bit, non-interlaced PNGs in grayscale / RGB / gray+alpha / RGBA
|
|
11
|
+
// (color types 0/2/4/6) — what `screencap` and `simctl` emit. Anything else
|
|
12
|
+
// (palette, 16-bit, interlaced) is left untouched and reported via `reason`,
|
|
13
|
+
// so a screenshot is never corrupted, only (sometimes) not shrunk.
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.downscalePng = downscalePng;
|
|
16
|
+
const node_zlib_1 = require("node:zlib");
|
|
17
|
+
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
18
|
+
/** Channel count for a supported PNG color type, or 0 if unsupported (e.g. palette). */
|
|
19
|
+
function channelsFor(colorType) {
|
|
20
|
+
switch (colorType) {
|
|
21
|
+
case 0: return 1; // grayscale
|
|
22
|
+
case 2: return 3; // RGB
|
|
23
|
+
case 4: return 2; // gray + alpha
|
|
24
|
+
case 6: return 4; // RGBA
|
|
25
|
+
default: return 0; // 3 = palette, etc. — unsupported
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function paeth(a, b, c) {
|
|
29
|
+
const p = a + b - c;
|
|
30
|
+
const pa = Math.abs(p - a);
|
|
31
|
+
const pb = Math.abs(p - b);
|
|
32
|
+
const pc = Math.abs(p - c);
|
|
33
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
34
|
+
}
|
|
35
|
+
/** Reverse PNG per-scanline filtering into packed pixels (height*width*channels bytes). */
|
|
36
|
+
function unfilter(data, width, height, ch) {
|
|
37
|
+
const stride = width * ch;
|
|
38
|
+
const raw = Buffer.alloc(height * stride);
|
|
39
|
+
let pos = 0;
|
|
40
|
+
let prev = Buffer.alloc(stride); // row above the first row is all zeros
|
|
41
|
+
for (let y = 0; y < height; y++) {
|
|
42
|
+
const ft = data[pos++];
|
|
43
|
+
const cur = raw.subarray(y * stride, (y + 1) * stride);
|
|
44
|
+
for (let x = 0; x < stride; x++) {
|
|
45
|
+
const filt = data[pos++];
|
|
46
|
+
const a = x >= ch ? cur[x - ch] : 0; // left
|
|
47
|
+
const b = prev[x]; // above
|
|
48
|
+
const c = x >= ch ? prev[x - ch] : 0; // above-left
|
|
49
|
+
let val;
|
|
50
|
+
switch (ft) {
|
|
51
|
+
case 1:
|
|
52
|
+
val = filt + a;
|
|
53
|
+
break; // Sub
|
|
54
|
+
case 2:
|
|
55
|
+
val = filt + b;
|
|
56
|
+
break; // Up
|
|
57
|
+
case 3:
|
|
58
|
+
val = filt + ((a + b) >> 1);
|
|
59
|
+
break; // Average
|
|
60
|
+
case 4:
|
|
61
|
+
val = filt + paeth(a, b, c);
|
|
62
|
+
break; // Paeth
|
|
63
|
+
default:
|
|
64
|
+
val = filt;
|
|
65
|
+
break; // None (0) / unknown
|
|
66
|
+
}
|
|
67
|
+
cur[x] = val & 0xff;
|
|
68
|
+
}
|
|
69
|
+
prev = cur;
|
|
70
|
+
}
|
|
71
|
+
return raw;
|
|
72
|
+
}
|
|
73
|
+
/** Box-average downscale of packed pixels from (sw,sh) to (tw,th). */
|
|
74
|
+
function boxDownscale(src, sw, sh, tw, th, ch) {
|
|
75
|
+
const dst = Buffer.alloc(tw * th * ch);
|
|
76
|
+
const acc = new Array(ch);
|
|
77
|
+
for (let dy = 0; dy < th; dy++) {
|
|
78
|
+
const sy0 = Math.floor((dy * sh) / th);
|
|
79
|
+
const sy1 = Math.max(sy0 + 1, Math.floor(((dy + 1) * sh) / th));
|
|
80
|
+
for (let dx = 0; dx < tw; dx++) {
|
|
81
|
+
const sx0 = Math.floor((dx * sw) / tw);
|
|
82
|
+
const sx1 = Math.max(sx0 + 1, Math.floor(((dx + 1) * sw) / tw));
|
|
83
|
+
acc.fill(0);
|
|
84
|
+
for (let sy = sy0; sy < sy1; sy++) {
|
|
85
|
+
let p = (sy * sw + sx0) * ch;
|
|
86
|
+
for (let sx = sx0; sx < sx1; sx++) {
|
|
87
|
+
for (let k = 0; k < ch; k++)
|
|
88
|
+
acc[k] += src[p + k];
|
|
89
|
+
p += ch;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const count = (sy1 - sy0) * (sx1 - sx0);
|
|
93
|
+
const d = (dy * tw + dx) * ch;
|
|
94
|
+
for (let k = 0; k < ch; k++)
|
|
95
|
+
dst[d + k] = Math.round(acc[k] / count);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return dst;
|
|
99
|
+
}
|
|
100
|
+
/** Prepend a None (0) filter byte to each scanline, ready for deflate. */
|
|
101
|
+
function applyNoneFilter(raw, w, h, ch) {
|
|
102
|
+
const stride = w * ch;
|
|
103
|
+
const out = Buffer.alloc(h * (stride + 1));
|
|
104
|
+
for (let y = 0; y < h; y++) {
|
|
105
|
+
out[y * (stride + 1)] = 0;
|
|
106
|
+
raw.copy(out, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
// CRC-32 (IEEE) for chunk integrity. node:zlib.crc32 only exists on newer Node,
|
|
111
|
+
// and the project targets Node ≥ 18, so we keep our own tiny table.
|
|
112
|
+
const CRC_TABLE = (() => {
|
|
113
|
+
const t = new Uint32Array(256);
|
|
114
|
+
for (let n = 0; n < 256; n++) {
|
|
115
|
+
let c = n;
|
|
116
|
+
for (let k = 0; k < 8; k++)
|
|
117
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
118
|
+
t[n] = c >>> 0;
|
|
119
|
+
}
|
|
120
|
+
return t;
|
|
121
|
+
})();
|
|
122
|
+
function crc32(buf) {
|
|
123
|
+
let c = 0xffffffff;
|
|
124
|
+
for (let i = 0; i < buf.length; i++)
|
|
125
|
+
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
126
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
127
|
+
}
|
|
128
|
+
function chunk(type, data) {
|
|
129
|
+
const len = Buffer.alloc(4);
|
|
130
|
+
len.writeUInt32BE(data.length, 0);
|
|
131
|
+
const typeBuf = Buffer.from(type, 'ascii');
|
|
132
|
+
const crc = Buffer.alloc(4);
|
|
133
|
+
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
|
134
|
+
return Buffer.concat([len, typeBuf, data, crc]);
|
|
135
|
+
}
|
|
136
|
+
function buildPng(w, h, bitDepth, colorType, idat) {
|
|
137
|
+
const ihdr = Buffer.alloc(13);
|
|
138
|
+
ihdr.writeUInt32BE(w, 0);
|
|
139
|
+
ihdr.writeUInt32BE(h, 4);
|
|
140
|
+
ihdr[8] = bitDepth;
|
|
141
|
+
ihdr[9] = colorType;
|
|
142
|
+
// compression, filter, interlace methods are all 0 (the only standard values)
|
|
143
|
+
return Buffer.concat([PNG_SIG, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Downscale a PNG so its longest edge is at most `maxEdge` px (never upscales).
|
|
147
|
+
* Returns the original buffer unchanged when it is already small enough or is in
|
|
148
|
+
* an unsupported PNG form; `reason` explains which.
|
|
149
|
+
*/
|
|
150
|
+
function downscalePng(input, maxEdge) {
|
|
151
|
+
const asis = (width, height, reason) => ({
|
|
152
|
+
buf: input, width, height, scaled: false, origWidth: width, origHeight: height, reason,
|
|
153
|
+
});
|
|
154
|
+
if (input.length < 8 || !input.subarray(0, 8).equals(PNG_SIG))
|
|
155
|
+
return asis(0, 0, 'not a PNG');
|
|
156
|
+
if (!(maxEdge >= 1))
|
|
157
|
+
return asis(0, 0, 'no target size');
|
|
158
|
+
// Walk chunks for IHDR + concatenated IDAT.
|
|
159
|
+
let width = 0, height = 0, bitDepth = 0, colorType = 0, interlace = 0;
|
|
160
|
+
let haveIhdr = false;
|
|
161
|
+
const idat = [];
|
|
162
|
+
let off = 8;
|
|
163
|
+
while (off + 8 <= input.length) {
|
|
164
|
+
const len = input.readUInt32BE(off);
|
|
165
|
+
const type = input.toString('ascii', off + 4, off + 8);
|
|
166
|
+
const start = off + 8;
|
|
167
|
+
if (start + len > input.length)
|
|
168
|
+
break; // truncated
|
|
169
|
+
if (type === 'IHDR' && len >= 13) {
|
|
170
|
+
width = input.readUInt32BE(start);
|
|
171
|
+
height = input.readUInt32BE(start + 4);
|
|
172
|
+
bitDepth = input[start + 8];
|
|
173
|
+
colorType = input[start + 9];
|
|
174
|
+
interlace = input[start + 12];
|
|
175
|
+
haveIhdr = true;
|
|
176
|
+
}
|
|
177
|
+
else if (type === 'IDAT') {
|
|
178
|
+
idat.push(input.subarray(start, start + len));
|
|
179
|
+
}
|
|
180
|
+
else if (type === 'IEND') {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
off = start + len + 4; // +4 skips the chunk CRC
|
|
184
|
+
}
|
|
185
|
+
if (!haveIhdr || width < 1 || height < 1)
|
|
186
|
+
return asis(width, height, 'no IHDR');
|
|
187
|
+
const ch = channelsFor(colorType);
|
|
188
|
+
if (bitDepth !== 8 || interlace !== 0 || ch === 0) {
|
|
189
|
+
return asis(width, height, 'unsupported PNG format (need 8-bit, non-interlaced, non-palette)');
|
|
190
|
+
}
|
|
191
|
+
if (Math.max(width, height) <= maxEdge)
|
|
192
|
+
return asis(width, height, 'already within target');
|
|
193
|
+
let raw;
|
|
194
|
+
try {
|
|
195
|
+
const inflated = (0, node_zlib_1.inflateSync)(Buffer.concat(idat));
|
|
196
|
+
if (inflated.length < height * (1 + width * ch))
|
|
197
|
+
return asis(width, height, 'short pixel data');
|
|
198
|
+
raw = unfilter(inflated, width, height, ch);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return asis(width, height, 'could not decode pixel data');
|
|
202
|
+
}
|
|
203
|
+
const scale = maxEdge / Math.max(width, height);
|
|
204
|
+
const tw = Math.max(1, Math.round(width * scale));
|
|
205
|
+
const th = Math.max(1, Math.round(height * scale));
|
|
206
|
+
const small = boxDownscale(raw, width, height, tw, th, ch);
|
|
207
|
+
const idatOut = (0, node_zlib_1.deflateSync)(applyNoneFilter(small, tw, th, ch));
|
|
208
|
+
return {
|
|
209
|
+
buf: buildPng(tw, th, bitDepth, colorType, idatOut),
|
|
210
|
+
width: tw, height: th, scaled: true, origWidth: width, origHeight: height,
|
|
211
|
+
};
|
|
212
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.setOutputQuiet = setOutputQuiet;
|
|
4
|
+
exports.out = out;
|
|
5
|
+
exports.err = err;
|
|
6
|
+
exports.json = json;
|
|
7
|
+
exports.artifactDir = artifactDir;
|
|
8
|
+
exports.defaultScreenshotPath = defaultScreenshotPath;
|
|
9
|
+
const node_fs_1 = require("node:fs");
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
11
|
+
// Output discipline: primary results -> stdout, diagnostics/errors -> stderr.
|
|
12
|
+
// Everything that prints data also supports --json for structured consumption.
|
|
13
|
+
// `vk ai` runs many leaf commands in one process; their per-step `out()`
|
|
14
|
+
// confirmations ("tapped …") would pollute stdout (which must stay the one
|
|
15
|
+
// parseable result). The engine sets quiet for the duration of the run so that
|
|
16
|
+
// per-step `out()` is suppressed while `err()` (stderr progress) still streams.
|
|
17
|
+
let quiet = false;
|
|
18
|
+
/** Suppress/restore `out()` (not `err()`/`json()`). Returns the previous value. */
|
|
19
|
+
function setOutputQuiet(q) {
|
|
20
|
+
const prev = quiet;
|
|
21
|
+
quiet = q;
|
|
22
|
+
return prev;
|
|
23
|
+
}
|
|
24
|
+
function out(s) {
|
|
25
|
+
if (quiet)
|
|
26
|
+
return;
|
|
27
|
+
process.stdout.write(s + '\n');
|
|
28
|
+
}
|
|
29
|
+
function err(s) {
|
|
30
|
+
process.stderr.write(s + '\n');
|
|
31
|
+
}
|
|
32
|
+
function json(obj) {
|
|
33
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
34
|
+
}
|
|
35
|
+
/** Local run artifacts live in ./.verikun (gitignored). */
|
|
36
|
+
function artifactDir() {
|
|
37
|
+
const dir = (0, node_path_1.resolve)(process.cwd(), '.verikun');
|
|
38
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
function defaultScreenshotPath() {
|
|
42
|
+
return (0, node_path_1.join)(artifactDir(), 'screen.png');
|
|
43
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Pure report rendering: a finished RunState in -> a JUnit XML / HTML string out.
|
|
3
|
+
// No fs, no device, no side effects — so it is trivially testable and the run
|
|
4
|
+
// recorder (run.ts) owns all the I/O. The data model lives in run.ts; we import
|
|
5
|
+
// the types only.
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.toJUnitXml = toJUnitXml;
|
|
8
|
+
exports.toHtml = toHtml;
|
|
9
|
+
// --- escaping -------------------------------------------------------------
|
|
10
|
+
// XML 1.0 forbids most control chars even when escaped; drop them so a stray
|
|
11
|
+
// byte in a UI label can't produce an unparseable report.
|
|
12
|
+
const stripCtl = (s) => s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
|
13
|
+
const xmlAttr = (s) => stripCtl(s)
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''');
|
|
19
|
+
const xmlText = (s) => stripCtl(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
20
|
+
const htmlEsc = (s) => s
|
|
21
|
+
.replace(/&/g, '&')
|
|
22
|
+
.replace(/</g, '<')
|
|
23
|
+
.replace(/>/g, '>')
|
|
24
|
+
.replace(/"/g, '"');
|
|
25
|
+
// --- shared labels --------------------------------------------------------
|
|
26
|
+
function selectorLabel(s) {
|
|
27
|
+
return s.selector ? `${s.selector.raw} (${s.selector.kind})` : '';
|
|
28
|
+
}
|
|
29
|
+
function resolvedLabel(s) {
|
|
30
|
+
const r = s.resolved;
|
|
31
|
+
if (!r)
|
|
32
|
+
return '';
|
|
33
|
+
const id = r.id || (r.idShort ? '@' + r.idShort : '') || r.type;
|
|
34
|
+
const text = r.text ? ` ${JSON.stringify(r.text)}` : '';
|
|
35
|
+
return `${id}${text} (${r.center.x},${r.center.y})`;
|
|
36
|
+
}
|
|
37
|
+
function fmtDuration(ms) {
|
|
38
|
+
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(2)} s`;
|
|
39
|
+
}
|
|
40
|
+
function counts(run) {
|
|
41
|
+
const passed = run.steps.filter((s) => s.status === 'passed').length;
|
|
42
|
+
const failures = run.steps.filter((s) => s.status === 'failed').length;
|
|
43
|
+
const errors = run.steps.filter((s) => s.status === 'error').length;
|
|
44
|
+
const timeMs = run.steps.reduce((a, s) => a + s.durationMs, 0);
|
|
45
|
+
return { tests: run.steps.length, passed, failures, errors, timeMs };
|
|
46
|
+
}
|
|
47
|
+
// --- JUnit ----------------------------------------------------------------
|
|
48
|
+
function toJUnitXml(run) {
|
|
49
|
+
const c = counts(run);
|
|
50
|
+
const suiteTime = (c.timeMs / 1000).toFixed(3);
|
|
51
|
+
const cases = run.steps
|
|
52
|
+
.map((s) => {
|
|
53
|
+
const time = (s.durationMs / 1000).toFixed(3);
|
|
54
|
+
const classname = 'verikun.' + s.command;
|
|
55
|
+
const attrs = `name="${xmlAttr(s.name)}" classname="${xmlAttr(classname)}" time="${time}"`;
|
|
56
|
+
const lines = [];
|
|
57
|
+
if (selectorLabel(s))
|
|
58
|
+
lines.push(`selector: ${selectorLabel(s)}`);
|
|
59
|
+
if (s.tier && s.tier !== 'exact')
|
|
60
|
+
lines.push(`healed: matched via ${s.tier}, not exact`);
|
|
61
|
+
if (s.healed)
|
|
62
|
+
lines.push(`model-healed: ${s.message ?? 'repaired'}`);
|
|
63
|
+
if (resolvedLabel(s))
|
|
64
|
+
lines.push(`resolved: ${resolvedLabel(s)}`);
|
|
65
|
+
if (s.failImage)
|
|
66
|
+
lines.push(`screenshot: ${s.failImage}`);
|
|
67
|
+
if (s.image)
|
|
68
|
+
lines.push(`image: ${s.image}`);
|
|
69
|
+
let body = '';
|
|
70
|
+
if (s.status === 'failed' || s.status === 'error') {
|
|
71
|
+
const tag = s.status === 'failed' ? 'failure' : 'error';
|
|
72
|
+
const type = s.status === 'failed' ? 'AssertionFailure' : 'EnvironmentError';
|
|
73
|
+
const detail = [
|
|
74
|
+
s.message ?? s.status,
|
|
75
|
+
...lines,
|
|
76
|
+
s.failHierarchy ? `\nUI hierarchy at failure:\n${s.failHierarchy}` : '',
|
|
77
|
+
s.logs ? `\nDevice logs:\n${s.logs}` : '',
|
|
78
|
+
]
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.join('\n');
|
|
81
|
+
body =
|
|
82
|
+
`\n <${tag} message="${xmlAttr(s.message ?? s.status)}" type="${type}">` +
|
|
83
|
+
`${xmlText(detail)}</${tag}>`;
|
|
84
|
+
}
|
|
85
|
+
else if (lines.length || s.logs) {
|
|
86
|
+
const sysOut = [...lines, s.logs ? `Device logs:\n${s.logs}` : ''].filter(Boolean).join('\n');
|
|
87
|
+
body = `\n <system-out>${xmlText(sysOut)}</system-out>`;
|
|
88
|
+
}
|
|
89
|
+
return ` <testcase ${attrs}>${body}\n </testcase>`;
|
|
90
|
+
})
|
|
91
|
+
.join('\n');
|
|
92
|
+
const suiteAttrs = `name="${xmlAttr(run.name)}" tests="${c.tests}" failures="${c.failures}" ` +
|
|
93
|
+
`errors="${c.errors}" time="${suiteTime}" timestamp="${xmlAttr(run.startedAt)}"`;
|
|
94
|
+
return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
95
|
+
`<testsuites name="verikun" tests="${c.tests}" failures="${c.failures}" errors="${c.errors}" time="${suiteTime}">\n` +
|
|
96
|
+
`<testsuite ${suiteAttrs}>\n` +
|
|
97
|
+
`${cases}\n` +
|
|
98
|
+
(run.ai
|
|
99
|
+
? ` <system-out>${xmlText('vk ai: ' +
|
|
100
|
+
run.ai.cost +
|
|
101
|
+
(run.ai.improvements.length ? '\nSuggested improvements:\n' + run.ai.improvements.join('\n') : ''))}</system-out>\n`
|
|
102
|
+
: '') +
|
|
103
|
+
`</testsuite>\n</testsuites>\n`);
|
|
104
|
+
}
|
|
105
|
+
// --- HTML -----------------------------------------------------------------
|
|
106
|
+
const STYLE = `
|
|
107
|
+
:root { --pass:#1a7f37; --fail:#cf222e; --err:#9a6700; --bg:#f6f8fa; --line:#d0d7de; --muted:#57606a; }
|
|
108
|
+
* { box-sizing: border-box; }
|
|
109
|
+
body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; color:#1f2328; background:var(--bg); }
|
|
110
|
+
.wrap { max-width: 920px; margin: 0 auto; padding: 24px 20px 64px; }
|
|
111
|
+
h1 { font-size: 20px; margin: 0 0 4px; }
|
|
112
|
+
.meta { color: var(--muted); font-size: 13px; margin-bottom: 16px; }
|
|
113
|
+
.meta code { background:#eaeef2; padding:1px 5px; border-radius:4px; }
|
|
114
|
+
.summary { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom: 20px; }
|
|
115
|
+
.chip { font-weight:600; font-size:13px; padding:4px 10px; border-radius:999px; color:#fff; }
|
|
116
|
+
.chip.pass{background:var(--pass)} .chip.fail{background:var(--fail)} .chip.err{background:var(--err)}
|
|
117
|
+
.chip.muted{ background:#eaeef2; color:var(--muted); }
|
|
118
|
+
ol.steps { list-style:none; margin:0; padding:0; }
|
|
119
|
+
li.step { background:#fff; border:1px solid var(--line); border-left-width:4px; border-radius:8px; margin-bottom:10px; padding:12px 14px; }
|
|
120
|
+
li.step.passed{ border-left-color:var(--pass) } li.step.failed{ border-left-color:var(--fail) } li.step.error{ border-left-color:var(--err) }
|
|
121
|
+
.row { display:flex; align-items:center; gap:10px; }
|
|
122
|
+
.idx { color:var(--muted); font-variant-numeric:tabular-nums; }
|
|
123
|
+
.st { font-weight:700; font-size:11px; letter-spacing:.04em; padding:2px 7px; border-radius:4px; color:#fff; }
|
|
124
|
+
.st.passed{background:var(--pass)} .st.failed{background:var(--fail)} .st.error{background:var(--err)}
|
|
125
|
+
.name { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; }
|
|
126
|
+
.time { margin-left:auto; color:var(--muted); font-variant-numeric:tabular-nums; }
|
|
127
|
+
.detail { margin-top:8px; font-size:13px; color:#1f2328; }
|
|
128
|
+
.detail .k { color:var(--muted); }
|
|
129
|
+
.detail code { background:#eaeef2; padding:1px 5px; border-radius:4px; font-size:12px; }
|
|
130
|
+
.msg { margin-top:6px; font-size:13px; }
|
|
131
|
+
.msg.fail { color:var(--fail); }
|
|
132
|
+
img.shot { display:block; margin-top:10px; max-width:300px; max-height:520px; border:1px solid var(--line); border-radius:6px; }
|
|
133
|
+
details { margin-top:8px; }
|
|
134
|
+
summary { cursor:pointer; color:var(--muted); font-size:13px; }
|
|
135
|
+
pre { background:#0d1117; color:#e6edf3; padding:12px; border-radius:6px; overflow:auto; font-size:12px; line-height:1.45; max-height:360px; }
|
|
136
|
+
.aibox { background:#fff; border:1px solid var(--line); border-radius:8px; padding:12px 14px; margin-bottom:20px; font-size:13px; }
|
|
137
|
+
.aibox .cost { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--muted); margin-top:4px; }
|
|
138
|
+
.aibox ul { margin:8px 0 0; padding-left:18px; }
|
|
139
|
+
`;
|
|
140
|
+
function aiPanelHtml(ai) {
|
|
141
|
+
const improvements = ai.improvements.length
|
|
142
|
+
? `<details open><summary>Suggested test improvements (${ai.improvements.length})</summary><ul>${ai.improvements
|
|
143
|
+
.map((s) => `<li>${htmlEsc(s)}</li>`)
|
|
144
|
+
.join('')}</ul></details>`
|
|
145
|
+
: '';
|
|
146
|
+
return `<div class="aibox">
|
|
147
|
+
<div><span class="k">vk ai</span> ${ai.ok ? 'passed' : 'did not pass'}${ai.modelRepairs ? ` · ${ai.modelRepairs} model repair(s)` : ''}</div>
|
|
148
|
+
<div class="cost">${htmlEsc(ai.cost)}</div>
|
|
149
|
+
${improvements}
|
|
150
|
+
</div>`;
|
|
151
|
+
}
|
|
152
|
+
function stepHtml(s) {
|
|
153
|
+
const detail = [];
|
|
154
|
+
if (selectorLabel(s))
|
|
155
|
+
detail.push(`<span class="k">selector</span> <code>${htmlEsc(s.selector.raw)}</code> <span class="k">(${htmlEsc(s.selector.kind)})</span>`);
|
|
156
|
+
if (s.tier && s.tier !== 'exact')
|
|
157
|
+
detail.push(`<span class="k">healed</span> <code>${htmlEsc(s.tier)}</code>`);
|
|
158
|
+
if (s.healed)
|
|
159
|
+
detail.push(`<span class="k">model-healed</span>`);
|
|
160
|
+
if (resolvedLabel(s))
|
|
161
|
+
detail.push(`<span class="k">resolved</span> <code>${htmlEsc(resolvedLabel(s))}</code>`);
|
|
162
|
+
const parts = [];
|
|
163
|
+
parts.push(`<div class="row">
|
|
164
|
+
<span class="idx">#${s.index}</span>
|
|
165
|
+
<span class="st ${s.status}">${s.status.toUpperCase()}</span>
|
|
166
|
+
<span class="name">${htmlEsc(s.name)}</span>
|
|
167
|
+
<span class="time">${fmtDuration(s.durationMs)}</span>
|
|
168
|
+
</div>`);
|
|
169
|
+
if (detail.length)
|
|
170
|
+
parts.push(`<div class="detail">${detail.join(' · ')}</div>`);
|
|
171
|
+
if (s.message)
|
|
172
|
+
parts.push(`<div class="msg ${s.status !== 'passed' ? 'fail' : ''}">${htmlEsc(s.message)}</div>`);
|
|
173
|
+
if (s.image)
|
|
174
|
+
parts.push(`<a href="${htmlEsc(s.image)}"><img class="shot" src="${htmlEsc(s.image)}" alt="screenshot"></a>`);
|
|
175
|
+
if (s.failImage)
|
|
176
|
+
parts.push(`<a href="${htmlEsc(s.failImage)}"><img class="shot" src="${htmlEsc(s.failImage)}" alt="screen at failure"></a>`);
|
|
177
|
+
if (s.failHierarchy)
|
|
178
|
+
parts.push(`<details><summary>UI hierarchy at failure</summary><pre>${htmlEsc(s.failHierarchy)}</pre></details>`);
|
|
179
|
+
if (s.logs)
|
|
180
|
+
parts.push(`<details><summary>Device logs</summary><pre>${htmlEsc(s.logs)}</pre></details>`);
|
|
181
|
+
return `<li class="step ${s.status}">${parts.join('\n ')}</li>`;
|
|
182
|
+
}
|
|
183
|
+
function toHtml(run) {
|
|
184
|
+
const c = counts(run);
|
|
185
|
+
const chips = [
|
|
186
|
+
`<span class="chip pass">${c.passed} passed</span>`,
|
|
187
|
+
c.failures ? `<span class="chip fail">${c.failures} failed</span>` : '',
|
|
188
|
+
c.errors ? `<span class="chip err">${c.errors} errors</span>` : '',
|
|
189
|
+
`<span class="chip muted">${c.tests} steps · ${fmtDuration(c.timeMs)}</span>`,
|
|
190
|
+
]
|
|
191
|
+
.filter(Boolean)
|
|
192
|
+
.join('\n ');
|
|
193
|
+
const metaBits = [
|
|
194
|
+
`<code>${htmlEsc(run.id)}</code>`,
|
|
195
|
+
htmlEsc(run.platform) + (run.device ? ` · ${htmlEsc(run.device)}` : ''),
|
|
196
|
+
`started ${htmlEsc(run.startedAt)}`,
|
|
197
|
+
run.finishedAt ? `finished ${htmlEsc(run.finishedAt)}` : '',
|
|
198
|
+
run.implicit ? 'implicit run' : '',
|
|
199
|
+
].filter(Boolean);
|
|
200
|
+
return `<!doctype html>
|
|
201
|
+
<html lang="en">
|
|
202
|
+
<head>
|
|
203
|
+
<meta charset="utf-8">
|
|
204
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
205
|
+
<title>verikun run — ${htmlEsc(run.name)}</title>
|
|
206
|
+
<style>${STYLE}</style>
|
|
207
|
+
</head>
|
|
208
|
+
<body>
|
|
209
|
+
<div class="wrap">
|
|
210
|
+
<h1>verikun test run — ${htmlEsc(run.name)}</h1>
|
|
211
|
+
<div class="meta">${metaBits.join(' · ')}</div>
|
|
212
|
+
<div class="summary">
|
|
213
|
+
${chips}
|
|
214
|
+
</div>
|
|
215
|
+
${run.ai ? aiPanelHtml(run.ai) : ''}
|
|
216
|
+
<ol class="steps">
|
|
217
|
+
${run.steps.map(stepHtml).join('\n ')}
|
|
218
|
+
</ol>
|
|
219
|
+
</div>
|
|
220
|
+
</body>
|
|
221
|
+
</html>
|
|
222
|
+
`;
|
|
223
|
+
}
|