verikun 0.29.0 → 0.30.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/.claude/skills/verikun/SKILL.md +5 -0
- package/CHANGELOG.md +28 -0
- package/dist/agent/engine.js +15 -1
- package/dist/agent/grammar.js +12 -2
- package/dist/agent/lint.js +104 -3
- package/dist/agent/remote.js +57 -6
- package/dist/cli.js +103 -6
- package/dist/errors.js +39 -1
- package/dist/report.js +2 -1
- package/dist/rpc.js +13 -0
- package/dist/server-http.js +5 -1
- package/dist/server.js +43 -19
- package/dist/suite.js +269 -15
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/errors.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// 2 usage error, ambiguous selector, or a device another job is driving (caller must refine)
|
|
7
7
|
// 3 environment error (adb/simctl missing, no usable device, dump failed)
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.AmbiguousSelectorError = exports.DUMP_KILLED_MESSAGE = exports.dumpKilledMessage = exports.DumpKilledError = exports.NO_WINDOW_MESSAGE = exports.NoWindowError = exports.TransientReadError = exports.SelectorNotFoundError = exports.probeFailure = exports.envError = exports.usageError = exports.CliError = void 0;
|
|
9
|
+
exports.AmbiguousSelectorError = exports.RunEvictedError = exports.NoFreeDeviceError = exports.DUMP_KILLED_MESSAGE = exports.dumpKilledMessage = exports.DumpKilledError = exports.NO_WINDOW_MESSAGE = exports.NoWindowError = exports.TransientReadError = exports.SelectorNotFoundError = exports.probeFailure = exports.envError = exports.usageError = exports.CliError = void 0;
|
|
10
10
|
exports.isEnvError = isEnvError;
|
|
11
11
|
class CliError extends Error {
|
|
12
12
|
exitCode;
|
|
@@ -140,6 +140,44 @@ exports.dumpKilledMessage = dumpKilledMessage;
|
|
|
140
140
|
exports.DUMP_KILLED_MESSAGE = 'The UI hierarchy dump was killed before it could answer — the device reclaiming memory ' +
|
|
141
141
|
'while an app starts, or another tool holding the one UiAutomation connection. This ' +
|
|
142
142
|
'normally clears within seconds.';
|
|
143
|
+
// --- Remote leases: a run that never got a device, and one that lost its device --------
|
|
144
|
+
//
|
|
145
|
+
// Both are exit 3 like any other environment failure — the exit-code contract does not move
|
|
146
|
+
// and `isEnvError` still holds. They are classes only so a PARALLEL suite can tell them apart
|
|
147
|
+
// from a broken box across a process boundary (`errorKind` in a lane child's --json). Read as
|
|
148
|
+
// ordinary environment failures they were issue #147: a pool that lost one phone recorded
|
|
149
|
+
// failures for tests that never ran, and retired a lane the server would have fed again
|
|
150
|
+
// minutes later.
|
|
151
|
+
/**
|
|
152
|
+
* A `vk server` refused this run a device: every device is leased (409) or none is serving
|
|
153
|
+
* (503). Built ONLY for `/v1/lease`, the call a run makes before it does anything, so it
|
|
154
|
+
* always means the test never started. `vk suite` hands such a test back to its queue and
|
|
155
|
+
* waits for a device instead of recording a failure.
|
|
156
|
+
*/
|
|
157
|
+
class NoFreeDeviceError extends CliError {
|
|
158
|
+
constructor(message) {
|
|
159
|
+
super(message, 3);
|
|
160
|
+
this.name = 'NoFreeDeviceError';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
exports.NoFreeDeviceError = NoFreeDeviceError;
|
|
164
|
+
/**
|
|
165
|
+
* The server ended this run part-way: its device left the pool, was taken over while the run
|
|
166
|
+
* sat idle, or was power-cycled — and a run is never re-homed onto another phone, because its
|
|
167
|
+
* earlier steps ran on this one. Only the server knows, so it TAGS the 409 (`errorKind`); an
|
|
168
|
+
* older server's eviction stays an ordinary environment failure.
|
|
169
|
+
*
|
|
170
|
+
* Nothing in the `vk ai` engine may absorb it: every later call is refused the same way, so a
|
|
171
|
+
* catch that treats it as a transient read turns a lost phone into an "absent" guard or a
|
|
172
|
+
* failed repair. `vk suite` re-runs the test as a fresh run without spending a retry.
|
|
173
|
+
*/
|
|
174
|
+
class RunEvictedError extends CliError {
|
|
175
|
+
constructor(message) {
|
|
176
|
+
super(message, 3);
|
|
177
|
+
this.name = 'RunEvictedError';
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
exports.RunEvictedError = RunEvictedError;
|
|
143
181
|
/** Selector matched >1 element. Exit 2. Carries the candidates so the agent runner
|
|
144
182
|
* can ask the model to disambiguate (a heal trigger) instead of aborting. */
|
|
145
183
|
class AmbiguousSelectorError extends CliError {
|
package/dist/report.js
CHANGED
|
@@ -278,7 +278,8 @@ const SUITE_STYLE = `
|
|
|
278
278
|
function suiteAttemptLinks(attempts, linkBase) {
|
|
279
279
|
const links = attempts
|
|
280
280
|
.map((a, i) => {
|
|
281
|
-
|
|
281
|
+
// Said outright, so nobody goes looking for a flake in a test whose phone was unplugged.
|
|
282
|
+
const label = `attempt ${i + 1}${a.evicted ? ' (device left the pool)' : ''}`;
|
|
282
283
|
if (!a.id)
|
|
283
284
|
return htmlEsc(label);
|
|
284
285
|
return `<a href="${htmlEsc(`${linkBase}runs/${encodeURIComponent(a.id)}/report.html`)}">${htmlEsc(label)}</a>`;
|
package/dist/rpc.js
CHANGED
|
@@ -34,6 +34,15 @@ function describeError(e) {
|
|
|
34
34
|
if (e instanceof errors_1.NoWindowError) {
|
|
35
35
|
return { kind: 'NoWindowError', name: e.name, message: e.message, exitCode: e.exitCode };
|
|
36
36
|
}
|
|
37
|
+
// Also before the CliError arm, for a different reader: `mapError` puts this kind in a
|
|
38
|
+
// lane child's --json, and it is how a parallel suite tells "never got a device" and "lost
|
|
39
|
+
// its device" from a broken box (#147). The server only ever SENDS the eviction.
|
|
40
|
+
if (e instanceof errors_1.NoFreeDeviceError) {
|
|
41
|
+
return { kind: 'NoFreeDeviceError', name: e.name, message: e.message, exitCode: e.exitCode };
|
|
42
|
+
}
|
|
43
|
+
if (e instanceof errors_1.RunEvictedError) {
|
|
44
|
+
return { kind: 'RunEvictedError', name: e.name, message: e.message, exitCode: e.exitCode };
|
|
45
|
+
}
|
|
37
46
|
if (e instanceof errors_1.CliError) {
|
|
38
47
|
return { kind: 'CliError', name: e.name, message: e.message, exitCode: e.exitCode };
|
|
39
48
|
}
|
|
@@ -51,6 +60,10 @@ function rebuildError(d) {
|
|
|
51
60
|
return new errors_1.NoWindowError(d.message);
|
|
52
61
|
case 'DumpKilledError':
|
|
53
62
|
return new errors_1.DumpKilledError(d.message);
|
|
63
|
+
case 'NoFreeDeviceError':
|
|
64
|
+
return new errors_1.NoFreeDeviceError(d.message);
|
|
65
|
+
case 'RunEvictedError':
|
|
66
|
+
return new errors_1.RunEvictedError(d.message);
|
|
54
67
|
case 'CliError':
|
|
55
68
|
return new errors_1.CliError(d.message, d.exitCode);
|
|
56
69
|
default: {
|
package/dist/server-http.js
CHANGED
|
@@ -15,6 +15,7 @@ class HttpError extends Error {
|
|
|
15
15
|
exitCode;
|
|
16
16
|
deviceChanged;
|
|
17
17
|
errorKind;
|
|
18
|
+
evicted;
|
|
18
19
|
constructor(status, message, exitCode = status === 400 || status === 404 || status === 413 ? 2 : 3,
|
|
19
20
|
/** Set when this request moved the server's device before failing — the client
|
|
20
21
|
* needs to know the ground shifted even though the answer is an error. */
|
|
@@ -23,12 +24,15 @@ class HttpError extends Error {
|
|
|
23
24
|
* HttpError is how the identity used to be lost: only `.message` and `.exitCode` were
|
|
24
25
|
* copied across, so a `NoWindowError` reached the client as a bare `CliError`. Carry it
|
|
25
26
|
* here and the outer catch can put it on the wire. */
|
|
26
|
-
errorKind
|
|
27
|
+
errorKind,
|
|
28
|
+
/** This request's failure evicted the caller's run (see ExecResponse.evicted). */
|
|
29
|
+
evicted) {
|
|
27
30
|
super(message);
|
|
28
31
|
this.status = status;
|
|
29
32
|
this.exitCode = exitCode;
|
|
30
33
|
this.deviceChanged = deviceChanged;
|
|
31
34
|
this.errorKind = errorKind;
|
|
35
|
+
this.evicted = evicted;
|
|
32
36
|
this.name = 'HttpError';
|
|
33
37
|
}
|
|
34
38
|
}
|
package/dist/server.js
CHANGED
|
@@ -891,11 +891,14 @@ function buildServer(config) {
|
|
|
891
891
|
* phone that is now gone, so continuing it elsewhere would produce a run whose steps
|
|
892
892
|
* came from two devices while the report named one. A new run token (a rerun) is served
|
|
893
893
|
* normally — this only closes the door on the run that was interrupted.
|
|
894
|
+
*
|
|
895
|
+
* Each keeps what it lost and why, because the run is TOLD: its 409 names the device, and a
|
|
896
|
+
* parallel suite's warning then names the phone that left rather than a lane slot (#147).
|
|
894
897
|
*/
|
|
895
|
-
const evicted = new
|
|
898
|
+
const evicted = new Map();
|
|
896
899
|
/**
|
|
897
900
|
* Bounded, because on a long-lived CI server every interrupted run leaves an entry and
|
|
898
|
-
* only its own `/v1/release` ever removes one. A
|
|
901
|
+
* only its own `/v1/release` ever removes one. A Map iterates in insertion order, so
|
|
899
902
|
* the front is the stalest — a client that died long ago and will never ask again.
|
|
900
903
|
*/
|
|
901
904
|
const EVICTED_CAP = 512;
|
|
@@ -906,17 +909,17 @@ function buildServer(config) {
|
|
|
906
909
|
function evict(token, why) {
|
|
907
910
|
const had = leases.get(token);
|
|
908
911
|
leases.delete(token);
|
|
909
|
-
evicted.
|
|
912
|
+
evicted.set(token, { serial: had?.serial, why });
|
|
910
913
|
// Every eviction is announced. This is the direct cause of the 409 a client then reads
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
// device that left.
|
|
914
|
+
// (a parallel suite re-runs the test as a fresh run; an older client read it as an
|
|
915
|
+
// environment failure and retired a lane over it), and it used to be the one lease
|
|
916
|
+
// transition that happened in complete silence, so a degrading run showed a burst of
|
|
917
|
+
// unexplained 409s with nothing anywhere connecting them to the device that left.
|
|
915
918
|
if (had)
|
|
916
919
|
(0, output_1.err)(`[server] lease: run ${token.slice(0, 8)}… evicted from ${had.serial} — ${why}`);
|
|
917
920
|
// `if`, not `while`: this adds exactly one entry, so at most one can be over.
|
|
918
921
|
if (evicted.size > EVICTED_CAP)
|
|
919
|
-
evicted.delete(evicted.
|
|
922
|
+
evicted.delete(evicted.keys().next().value);
|
|
920
923
|
}
|
|
921
924
|
function evictHoldersOf(serial, why) {
|
|
922
925
|
for (const [token, lease] of leases)
|
|
@@ -1025,11 +1028,14 @@ function buildServer(config) {
|
|
|
1025
1028
|
*/
|
|
1026
1029
|
let exclusive = null;
|
|
1027
1030
|
function busyError(token) {
|
|
1028
|
-
|
|
1031
|
+
const lost = token === undefined ? undefined : evicted.get(token);
|
|
1032
|
+
if (lost) {
|
|
1029
1033
|
// Named plainly, because "device is busy" would send the operator looking for a
|
|
1030
|
-
// racing job that does not exist.
|
|
1031
|
-
|
|
1032
|
-
|
|
1034
|
+
// racing job that does not exist. TAGGED, because the client cannot tell an eviction
|
|
1035
|
+
// from contention any other way: a parallel suite re-runs an evicted test as a fresh
|
|
1036
|
+
// run without spending a retry, but must never do that for a busy pool (#147).
|
|
1037
|
+
return new server_http_1.HttpError(409, `this run lost its device${lost.serial ? ` (${lost.serial})` : ''}: ${lost.why} — ` +
|
|
1038
|
+
'start a fresh run; this one cannot continue on another device', 3, undefined, 'RunEvictedError');
|
|
1033
1039
|
}
|
|
1034
1040
|
// An empty pool never reaches here: the deviceless guard in the router answers 503 for
|
|
1035
1041
|
// every route that takes a lease, and it names `lostDevice` while doing it. So `n` is
|
|
@@ -1119,7 +1125,7 @@ function buildServer(config) {
|
|
|
1119
1125
|
evict(t, why);
|
|
1120
1126
|
}
|
|
1121
1127
|
}
|
|
1122
|
-
async function handleExec(handle, req, res) {
|
|
1128
|
+
async function handleExec(handle, req, res, token) {
|
|
1123
1129
|
const body = await (0, server_http_1.readBody)(req, EXEC_BODY_CAP);
|
|
1124
1130
|
let parsed;
|
|
1125
1131
|
try {
|
|
@@ -1160,7 +1166,12 @@ function buildServer(config) {
|
|
|
1160
1166
|
}
|
|
1161
1167
|
catch (e) {
|
|
1162
1168
|
const changed = await considerFailover(e, 'exec', handle);
|
|
1163
|
-
|
|
1169
|
+
// Evicted by the failover this very failure triggered: say so on the error itself (#147).
|
|
1170
|
+
const lost = evicted.has(token);
|
|
1171
|
+
if (lost && !changed) {
|
|
1172
|
+
throw new server_http_1.HttpError(500, e.message, e instanceof errors_1.CliError ? e.exitCode : 3, undefined, (0, rpc_1.describeError)(e).kind, true);
|
|
1173
|
+
}
|
|
1174
|
+
throw changed ? new server_http_1.HttpError(500, e.message, e instanceof errors_1.CliError ? e.exitCode : 3, changed, undefined, lost || undefined) : e;
|
|
1164
1175
|
}
|
|
1165
1176
|
const { code, error, step, artifacts, logStart } = outcome;
|
|
1166
1177
|
(0, output_1.err)(`[server] ${handle.serial} exec ${node.command} ${node.positionals.join(' ')} → exit ${code} (${Date.now() - t0}ms)`);
|
|
@@ -1177,13 +1188,17 @@ function buildServer(config) {
|
|
|
1177
1188
|
code,
|
|
1178
1189
|
...(error ? { error } : {}),
|
|
1179
1190
|
...(deviceChanged ? { deviceChanged } : {}),
|
|
1191
|
+
// The failover this failure triggered shed the device and evicted this run. MEASURED on
|
|
1192
|
+
// hardware: this response is the only one that knows — the client's next request is
|
|
1193
|
+
// usually its release, which clears the mark (#147).
|
|
1194
|
+
...(evicted.has(token) ? { evicted: true } : {}),
|
|
1180
1195
|
...(step ? { step } : {}),
|
|
1181
1196
|
...(artifacts && Object.keys(artifacts).length ? { artifacts: (0, server_http_1.encodeArtifacts)(artifacts) } : {}),
|
|
1182
1197
|
...(logStart ? { logStart } : {}),
|
|
1183
1198
|
};
|
|
1184
1199
|
(0, server_http_1.sendJson)(res, 200, payload);
|
|
1185
1200
|
}
|
|
1186
|
-
async function handleElements(handle, req, res) {
|
|
1201
|
+
async function handleElements(handle, req, res, token) {
|
|
1187
1202
|
await (0, server_http_1.readBody)(req, EXEC_BODY_CAP); // drain (the body is unused; keeps keep-alive sane)
|
|
1188
1203
|
try {
|
|
1189
1204
|
const elements = await handle.elements(); // CliError(3) on dump failure → 500 below
|
|
@@ -1198,12 +1213,13 @@ function buildServer(config) {
|
|
|
1198
1213
|
// hierarchy from somewhere else is worse than an error. The client's connect probe
|
|
1199
1214
|
// re-asks after a reported move — see remote.ts's preflight.
|
|
1200
1215
|
const deviceChanged = await considerFailover(e, 'read', handle);
|
|
1201
|
-
|
|
1216
|
+
const lost = evicted.has(token);
|
|
1217
|
+
if (!deviceChanged && !lost)
|
|
1202
1218
|
throw e;
|
|
1203
1219
|
// describeError, not just .message/.exitCode: this wrap is on the path a mid-launch
|
|
1204
1220
|
// NoWindowError takes, and the engine's guard tells "still drawing" from "box broken"
|
|
1205
1221
|
// by class alone (issue #80).
|
|
1206
|
-
throw new server_http_1.HttpError(500, e.message, e instanceof errors_1.CliError ? e.exitCode : 3, deviceChanged, (0, rpc_1.describeError)(e).kind);
|
|
1222
|
+
throw new server_http_1.HttpError(500, e.message, e instanceof errors_1.CliError ? e.exitCode : 3, deviceChanged, (0, rpc_1.describeError)(e).kind, lost || undefined);
|
|
1207
1223
|
}
|
|
1208
1224
|
}
|
|
1209
1225
|
async function handleLogs(handle, req, res) {
|
|
@@ -1771,6 +1787,13 @@ function buildServer(config) {
|
|
|
1771
1787
|
(0, server_http_1.sendJson)(res, 200, body);
|
|
1772
1788
|
return;
|
|
1773
1789
|
}
|
|
1790
|
+
// A run that was EVICTED is told so first, however empty the pool has since become: it
|
|
1791
|
+
// lost its phone part-way, which is not the same as a new run finding none, and a
|
|
1792
|
+
// parallel suite re-runs only the former as a fresh run (#147). Shedding a pool's last
|
|
1793
|
+
// device is exactly when both are true at once.
|
|
1794
|
+
if (served.size === 0 && evicted.has(token) && (path === '/v1/exec' || path === '/v1/elements' || path === '/v1/logs')) {
|
|
1795
|
+
throw busyError(token);
|
|
1796
|
+
}
|
|
1774
1797
|
// A deviceless server must not silently fail every command. In-memory check, so
|
|
1775
1798
|
// the normal path is untouched. NOTE this fires only when the server NEVER
|
|
1776
1799
|
// resolved a device — one that DIED mid-run still has a non-null binding and keeps
|
|
@@ -1811,9 +1834,9 @@ function buildServer(config) {
|
|
|
1811
1834
|
return holdingLease(token, () => fn(h));
|
|
1812
1835
|
};
|
|
1813
1836
|
if (req.method === 'POST' && path === '/v1/exec')
|
|
1814
|
-
return onLeasedDevice((h) => handleExec(h, req, res));
|
|
1837
|
+
return onLeasedDevice((h) => handleExec(h, req, res, token));
|
|
1815
1838
|
if (req.method === 'POST' && path === '/v1/elements')
|
|
1816
|
-
return onLeasedDevice((h) => handleElements(h, req, res));
|
|
1839
|
+
return onLeasedDevice((h) => handleElements(h, req, res, token));
|
|
1817
1840
|
if (req.method === 'POST' && path === '/v1/logs')
|
|
1818
1841
|
return onLeasedDevice((h) => handleLogs(h, req, res));
|
|
1819
1842
|
if (req.method === 'POST' && path === '/v1/install') {
|
|
@@ -1891,6 +1914,7 @@ function buildServer(config) {
|
|
|
1891
1914
|
exitCode: mapped.exitCode,
|
|
1892
1915
|
...(errorKind ? { errorKind } : {}),
|
|
1893
1916
|
...(mapped.deviceChanged ? { deviceChanged: mapped.deviceChanged } : {}),
|
|
1917
|
+
...(mapped.evicted ? { evicted: true } : {}),
|
|
1894
1918
|
};
|
|
1895
1919
|
(0, server_http_1.sendJson)(res, mapped.status, body);
|
|
1896
1920
|
}
|