@devasign/verify 1.0.1 → 1.3.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 +2 -0
- package/dist/cli.js +172 -34
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -16,3 +16,5 @@ Guarantees:
|
|
|
16
16
|
- Setup problems (no start command, missing secret names, wrong runtime) are uploaded as a structured diagnosis, and the process exits 0.
|
|
17
17
|
|
|
18
18
|
Local run against a dev backend: mint a token with `backend/scripts/verify-dev-token.ts`, then `devasign-verify run --api-url http://localhost:8787 --token <jwt> --pr <n> --sha <head-sha>`. Offline: `--plan-file plan.json --results-out results.json`.
|
|
19
|
+
|
|
20
|
+
Exit code: 0 unless `--fail-on verdict` (a criterion failed) or `--fail-on unverifiable` (a criterion failed or could not be verified) is set; unset, the repository's DevAsign setting applies. An unknown `--fail-on` value exits 2. Criteria the plan could not cover are printed as warnings with their reason and fix link, and the Actions step summary lists every criterion. On Actions the CLI also writes `run-id`, `outcome` and `browsers` to `GITHUB_OUTPUT`.
|
package/dist/cli.js
CHANGED
|
@@ -7422,6 +7422,15 @@ function readText(root, rel) {
|
|
|
7422
7422
|
return null;
|
|
7423
7423
|
}
|
|
7424
7424
|
}
|
|
7425
|
+
function manifestNames(text) {
|
|
7426
|
+
if (!text) return [];
|
|
7427
|
+
try {
|
|
7428
|
+
const j = JSON.parse(text);
|
|
7429
|
+
return [...Object.keys(j?.dependencies || {}), ...Object.keys(j?.devDependencies || {}), ...typeof j?.name === "string" ? [j.name] : []];
|
|
7430
|
+
} catch {
|
|
7431
|
+
return [];
|
|
7432
|
+
}
|
|
7433
|
+
}
|
|
7425
7434
|
function envVarNames(text) {
|
|
7426
7435
|
if (!text) return [];
|
|
7427
7436
|
const out = /* @__PURE__ */ new Set();
|
|
@@ -7463,6 +7472,11 @@ async function detectSetup(root, opts = {}) {
|
|
|
7463
7472
|
}
|
|
7464
7473
|
const deps = { ...pkg?.dependencies || {}, ...pkg?.devDependencies || {} };
|
|
7465
7474
|
const dep = (name) => name in deps ? String(deps[name]).replace(/^[\^~>=<\s]+/, "") : void 0;
|
|
7475
|
+
const dependencies = pkg ? [
|
|
7476
|
+
...new Set(
|
|
7477
|
+
paths.filter((p) => /^[^/]+\/[^/]+\/package\.json$/.test(p)).reduce((acc, p) => acc.concat(manifestNames(readText(root, p))), Object.keys(deps))
|
|
7478
|
+
)
|
|
7479
|
+
].sort() : void 0;
|
|
7466
7480
|
const langCounts = /* @__PURE__ */ new Map();
|
|
7467
7481
|
for (const p of paths) {
|
|
7468
7482
|
const lang = LANG_BY_EXT[p.split(".").pop()?.toLowerCase() || ""];
|
|
@@ -7509,6 +7523,7 @@ async function detectSetup(root, opts = {}) {
|
|
|
7509
7523
|
packageManager,
|
|
7510
7524
|
monorepo,
|
|
7511
7525
|
frameworks,
|
|
7526
|
+
dependencies,
|
|
7512
7527
|
testCommands,
|
|
7513
7528
|
envExampleVars: envVars,
|
|
7514
7529
|
existingWorkflows: paths.filter((p) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(p)),
|
|
@@ -7577,7 +7592,17 @@ function diagnosePlaywrightOutput(output) {
|
|
|
7577
7592
|
}
|
|
7578
7593
|
|
|
7579
7594
|
// src/log.ts
|
|
7595
|
+
import { appendFileSync } from "node:fs";
|
|
7580
7596
|
var onActions = () => process.env.GITHUB_ACTIONS === "true";
|
|
7597
|
+
function setOutput(name, value) {
|
|
7598
|
+
const file = process.env.GITHUB_OUTPUT;
|
|
7599
|
+
if (!file) return;
|
|
7600
|
+
try {
|
|
7601
|
+
appendFileSync(file, `${name}=${value.replace(/\r?\n/g, " ")}
|
|
7602
|
+
`);
|
|
7603
|
+
} catch {
|
|
7604
|
+
}
|
|
7605
|
+
}
|
|
7581
7606
|
var log = {
|
|
7582
7607
|
info(msg) {
|
|
7583
7608
|
console.log(`devasign: ${msg}`);
|
|
@@ -7627,11 +7652,11 @@ function staticTokenSource(token) {
|
|
|
7627
7652
|
}
|
|
7628
7653
|
|
|
7629
7654
|
// src/run.ts
|
|
7630
|
-
import { appendFileSync, existsSync as existsSync5, writeFileSync as writeFileSync3 } from "node:fs";
|
|
7655
|
+
import { appendFileSync as appendFileSync2, existsSync as existsSync5, writeFileSync as writeFileSync3 } from "node:fs";
|
|
7631
7656
|
import path7 from "node:path";
|
|
7632
7657
|
|
|
7633
7658
|
// src/types.ts
|
|
7634
|
-
var CLI_VERSION = "1.0
|
|
7659
|
+
var CLI_VERSION = "1.2.0";
|
|
7635
7660
|
|
|
7636
7661
|
// src/api.ts
|
|
7637
7662
|
var ApiError = class extends Error {
|
|
@@ -7842,7 +7867,7 @@ var ASSERTION = {
|
|
|
7842
7867
|
go: /^--- FAIL\b|^FAIL\b/m,
|
|
7843
7868
|
playwright: /expect\(|Timed out .* expect|toBeVisible|toHaveText|toContainText|toHaveURL|toBeChecked|toHaveValue|toHaveCount|Expected:|Received:/m
|
|
7844
7869
|
};
|
|
7845
|
-
var INFRA = /Cannot find module|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND|SyntaxError|ImportError|ModuleNotFoundError|command not found|ENOENT|no test files|no tests found|No tests found|collected 0 items|\[build failed\]|Executable doesn't exist|browserType\.launch|net::ERR_|ECONNREFUSED|Process from config\.webServer|Timed out waiting .* from config\.webServer/i;
|
|
7870
|
+
var INFRA = /Cannot find module|Cannot find package|Failed to load url|Failed to resolve import|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND|ERR_PACKAGE_PATH_NOT_EXPORTED|SyntaxError|ImportError|ModuleNotFoundError|command not found|ENOENT|no test files|no tests found|No tests found|collected 0 items|\[build failed\]|Executable doesn't exist|browserType\.launch|net::ERR_|ECONNREFUSED|Process from config\.webServer|Timed out waiting .* from config\.webServer/i;
|
|
7846
7871
|
function classifyAttempt(runner, r) {
|
|
7847
7872
|
r = { ...r, output: (r.output || "").replace(/\u001b\[[0-9;]*m/g, "") };
|
|
7848
7873
|
if (r.spawnError) return { status: "error", error: `could not start test runner: ${r.spawnError}` };
|
|
@@ -7889,6 +7914,7 @@ import { spawn } from "node:child_process";
|
|
|
7889
7914
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
7890
7915
|
import path3 from "node:path";
|
|
7891
7916
|
var MAX_CAPTURE = 5 * 1024 * 1024;
|
|
7917
|
+
var DRAIN_MS = 2e3;
|
|
7892
7918
|
async function runCommand(opts) {
|
|
7893
7919
|
const started = Date.now();
|
|
7894
7920
|
return new Promise((resolve) => {
|
|
@@ -7897,12 +7923,28 @@ async function runCommand(opts) {
|
|
|
7897
7923
|
let output = "";
|
|
7898
7924
|
let timedOut = false;
|
|
7899
7925
|
let settled = false;
|
|
7926
|
+
let drain;
|
|
7927
|
+
let exited = null;
|
|
7928
|
+
const armDrain = () => {
|
|
7929
|
+
if (!exited) return;
|
|
7930
|
+
clearTimeout(drain);
|
|
7931
|
+
drain = setTimeout(() => finish({ ...exited, timedOut }), DRAIN_MS);
|
|
7932
|
+
};
|
|
7900
7933
|
const env = { ...process.env, ...opts.env || {}, CI: "true", FORCE_COLOR: "0" };
|
|
7901
7934
|
for (const k of Object.keys(env)) if (k.startsWith("NODE_TEST_")) delete env[k];
|
|
7902
|
-
const
|
|
7935
|
+
const group = process.platform !== "win32";
|
|
7936
|
+
const child = spawn(opts.cmd, opts.args, { cwd: opts.cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: group });
|
|
7937
|
+
const killAll = () => {
|
|
7938
|
+
try {
|
|
7939
|
+
if (group && child.pid) process.kill(-child.pid, "SIGKILL");
|
|
7940
|
+
else child.kill("SIGKILL");
|
|
7941
|
+
} catch {
|
|
7942
|
+
child.kill("SIGKILL");
|
|
7943
|
+
}
|
|
7944
|
+
};
|
|
7903
7945
|
const timer = setTimeout(() => {
|
|
7904
7946
|
timedOut = true;
|
|
7905
|
-
|
|
7947
|
+
killAll();
|
|
7906
7948
|
}, opts.timeoutMs);
|
|
7907
7949
|
const take = (chunk, which) => {
|
|
7908
7950
|
const s = chunk.toString("utf8");
|
|
@@ -7912,6 +7954,7 @@ async function runCommand(opts) {
|
|
|
7912
7954
|
if (opts.onLine) {
|
|
7913
7955
|
for (const line of s.split("\n")) if (line) opts.onLine(line);
|
|
7914
7956
|
}
|
|
7957
|
+
armDrain();
|
|
7915
7958
|
};
|
|
7916
7959
|
child.stdout?.on("data", (c) => take(c, "out"));
|
|
7917
7960
|
child.stderr?.on("data", (c) => take(c, "err"));
|
|
@@ -7919,6 +7962,7 @@ async function runCommand(opts) {
|
|
|
7919
7962
|
if (settled) return;
|
|
7920
7963
|
settled = true;
|
|
7921
7964
|
clearTimeout(timer);
|
|
7965
|
+
clearTimeout(drain);
|
|
7922
7966
|
const full = { ...result, stdout, stderr, output, durationMs: Date.now() - started };
|
|
7923
7967
|
if (opts.logFile) {
|
|
7924
7968
|
try {
|
|
@@ -7935,6 +7979,10 @@ ${output}
|
|
|
7935
7979
|
resolve(full);
|
|
7936
7980
|
};
|
|
7937
7981
|
child.on("error", (err) => finish({ code: null, signal: null, timedOut, spawnError: err.message }));
|
|
7982
|
+
child.on("exit", (code, signal) => {
|
|
7983
|
+
exited = { code, signal };
|
|
7984
|
+
armDrain();
|
|
7985
|
+
});
|
|
7938
7986
|
child.on("close", (code, signal) => finish({ code, signal, timedOut }));
|
|
7939
7987
|
});
|
|
7940
7988
|
}
|
|
@@ -7983,7 +8031,7 @@ async function runFileTests(args) {
|
|
|
7983
8031
|
log.info(`attempt ${n}/${max}: ${c.status}${c.error ? ` \u2014 ${c.error}` : ""}`);
|
|
7984
8032
|
if (c.status === "pass" && n === 1) break;
|
|
7985
8033
|
if (c.status === "pass" && n > 1) break;
|
|
7986
|
-
if (c.status === "error" && max > 1 && n === 1 && /could not start|ENOENT|Cannot find module/.test(c.error || "")) break;
|
|
8034
|
+
if (c.status === "error" && max > 1 && n === 1 && /could not start|ENOENT|Cannot find module|Cannot find package|Failed to (?:load url|resolve import)/.test(c.error || "")) break;
|
|
7987
8035
|
}
|
|
7988
8036
|
log.endGroup();
|
|
7989
8037
|
const status = aggregateAttempts(attempts.map((a) => a.status));
|
|
@@ -8009,7 +8057,8 @@ async function runFileTests(args) {
|
|
|
8009
8057
|
|
|
8010
8058
|
// src/runners/playwright.ts
|
|
8011
8059
|
import { createRequire as createRequire2 } from "node:module";
|
|
8012
|
-
import { existsSync as existsSync3, readFileSync as readFileSync4, statSync as statSync3 } from "node:fs";
|
|
8060
|
+
import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync3 } from "node:fs";
|
|
8061
|
+
import { homedir } from "node:os";
|
|
8013
8062
|
import path5 from "node:path";
|
|
8014
8063
|
var require3 = createRequire2(import.meta.url);
|
|
8015
8064
|
function playwrightCli(root) {
|
|
@@ -8029,9 +8078,17 @@ function generatePlaywrightConfig(a) {
|
|
|
8029
8078
|
baseImport,
|
|
8030
8079
|
"const b: any = (__base as any)?.default ?? __base ?? {};",
|
|
8031
8080
|
"// One browser project only: recordings once, and no browsers we did not install.",
|
|
8032
|
-
"
|
|
8033
|
-
"
|
|
8034
|
-
"
|
|
8081
|
+
"// Project fields win over config fields in Playwright, so the customer's own",
|
|
8082
|
+
"// testDir/retries/recording settings are stripped and ours are re-applied here;",
|
|
8083
|
+
"// dependencies/teardown would reference projects we did not keep.",
|
|
8084
|
+
"const __pick = (ps: any[]) => ps.find((p: any) => /chrom/i.test(String(p?.name || ''))) ?? ps[0];",
|
|
8085
|
+
"const __clean = (p: any) => {",
|
|
8086
|
+
" const q: any = { ...p };",
|
|
8087
|
+
" for (const k of ['dependencies', 'teardown', 'testDir', 'testMatch', 'testIgnore', 'retries', 'outputDir', 'grep', 'grepInvert', 'snapshotDir']) delete q[k];",
|
|
8088
|
+
" q.use = { ...(q.use || {}), video: 'on', trace: 'on', screenshot: 'on' };",
|
|
8089
|
+
" return q;",
|
|
8090
|
+
"};",
|
|
8091
|
+
"const projects = Array.isArray(b.projects) && b.projects.length ? [__clean(__pick(b.projects))] : undefined;",
|
|
8035
8092
|
"export default defineConfig({",
|
|
8036
8093
|
" ...b,",
|
|
8037
8094
|
` testDir: ${j(a.testDir)},`,
|
|
@@ -8071,6 +8128,20 @@ function stripAnsi(s) {
|
|
|
8071
8128
|
}
|
|
8072
8129
|
var KIND_BY_ATTACHMENT = { video: "video", trace: "trace", screenshot: "screenshot" };
|
|
8073
8130
|
var TYPE_BY_KIND = { video: "video/webm", trace: "application/zip", screenshot: "image/png" };
|
|
8131
|
+
function testOutcome(retries) {
|
|
8132
|
+
if (!retries.length) return "error";
|
|
8133
|
+
const last = retries[retries.length - 1];
|
|
8134
|
+
if (last === "pass") return retries.every((s) => s === "pass") ? "pass" : "flaky";
|
|
8135
|
+
return retries.includes("fail") ? "fail" : "error";
|
|
8136
|
+
}
|
|
8137
|
+
function fileStatus(perTest) {
|
|
8138
|
+
const outcomes = perTest.map(testOutcome);
|
|
8139
|
+
if (!outcomes.length) return "error";
|
|
8140
|
+
if (outcomes.includes("fail")) return "fail";
|
|
8141
|
+
if (outcomes.includes("flaky")) return "flaky";
|
|
8142
|
+
if (outcomes.includes("error")) return "error";
|
|
8143
|
+
return "pass";
|
|
8144
|
+
}
|
|
8074
8145
|
function mapReport(report, tests, ws, artifacts, overallOutput) {
|
|
8075
8146
|
const specs = flattenSpecs(report);
|
|
8076
8147
|
const results = [];
|
|
@@ -8079,8 +8150,11 @@ function mapReport(report, tests, ws, artifacts, overallOutput) {
|
|
|
8079
8150
|
const attempts = [];
|
|
8080
8151
|
let n = 0;
|
|
8081
8152
|
let anyResult = false;
|
|
8153
|
+
const perTest = [];
|
|
8082
8154
|
for (const spec of mine) {
|
|
8083
8155
|
for (const pt of spec.tests) {
|
|
8156
|
+
const own = [];
|
|
8157
|
+
perTest.push(own);
|
|
8084
8158
|
for (const r of [...pt.results || []].sort((x, y) => (x.retry ?? 0) - (y.retry ?? 0))) {
|
|
8085
8159
|
anyResult = true;
|
|
8086
8160
|
n += 1;
|
|
@@ -8106,6 +8180,7 @@ function mapReport(report, tests, ws, artifacts, overallOutput) {
|
|
|
8106
8180
|
artifacts.push({ clientRef: posterRef, kind: "poster", path: screenshotPath, displayPath: ws.relative(screenshotPath), contentType: "image/png", testId: t.id, criterionIds: t.criterionIds, attempt: n, posterFor: videoRef });
|
|
8107
8181
|
refs.push(posterRef);
|
|
8108
8182
|
}
|
|
8183
|
+
own.push(status2);
|
|
8109
8184
|
attempts.push({ n, status: status2, durationMs: Math.round(r.duration ?? 0), error: status2 === "pass" ? void 0 : stripAnsi(message || r.status).split("\n").slice(0, 3).join(" ").slice(0, 500), artifactIds: refs });
|
|
8110
8185
|
}
|
|
8111
8186
|
}
|
|
@@ -8113,10 +8188,9 @@ function mapReport(report, tests, ws, artifacts, overallOutput) {
|
|
|
8113
8188
|
if (!anyResult) {
|
|
8114
8189
|
const err = report.errors?.[0]?.message || overallOutput.split("\n").find((l) => /Error|error/.test(l)) || "no result for this test";
|
|
8115
8190
|
attempts.push({ n: 1, status: "error", durationMs: 0, error: err.slice(0, 500), artifactIds: [] });
|
|
8191
|
+
perTest.push(["error"]);
|
|
8116
8192
|
}
|
|
8117
|
-
const
|
|
8118
|
-
const passes = statuses.filter((s) => s === "pass").length;
|
|
8119
|
-
const status = passes === statuses.length ? "pass" : passes > 0 ? "flaky" : statuses.includes("fail") ? "fail" : "error";
|
|
8193
|
+
const status = fileStatus(perTest.filter((t2) => t2.length));
|
|
8120
8194
|
results.push({
|
|
8121
8195
|
id: `r-${t.id}`,
|
|
8122
8196
|
testId: t.id,
|
|
@@ -8134,7 +8208,24 @@ function mapReport(report, tests, ws, artifacts, overallOutput) {
|
|
|
8134
8208
|
}
|
|
8135
8209
|
return results;
|
|
8136
8210
|
}
|
|
8211
|
+
function playwrightBrowsersRoot(env = process.env, home = homedir()) {
|
|
8212
|
+
if (env.PLAYWRIGHT_BROWSERS_PATH) return env.PLAYWRIGHT_BROWSERS_PATH;
|
|
8213
|
+
if (process.platform === "darwin") return path5.join(home, "Library", "Caches", "ms-playwright");
|
|
8214
|
+
if (process.platform === "win32") return path5.join(env.LOCALAPPDATA || home, "ms-playwright");
|
|
8215
|
+
return path5.join(home, ".cache", "ms-playwright");
|
|
8216
|
+
}
|
|
8217
|
+
function hasChromium(root, read = readdirSync2) {
|
|
8218
|
+
try {
|
|
8219
|
+
return read(root).some((d) => /^chromium(_headless_shell)?-\d+$/.test(d));
|
|
8220
|
+
} catch {
|
|
8221
|
+
return false;
|
|
8222
|
+
}
|
|
8223
|
+
}
|
|
8137
8224
|
async function ensureBrowsers(root, ws) {
|
|
8225
|
+
if (hasChromium(playwrightBrowsersRoot())) {
|
|
8226
|
+
log.info("Chromium already installed; skipping download");
|
|
8227
|
+
return { ok: true, log: "cached" };
|
|
8228
|
+
}
|
|
8138
8229
|
const cli = playwrightCli(root);
|
|
8139
8230
|
const args = [...cli.args, "install", ...process.platform === "linux" ? ["--with-deps"] : [], "chromium"];
|
|
8140
8231
|
log.info("installing Chromium for Playwright");
|
|
@@ -8235,9 +8326,16 @@ var Workspace = class {
|
|
|
8235
8326
|
|
|
8236
8327
|
// src/run.ts
|
|
8237
8328
|
var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
8329
|
+
var MIN_SERVER_DEADLINE_MS = 3e4;
|
|
8330
|
+
function serverDeadline(current, startedAt, hintMs) {
|
|
8331
|
+
if (typeof hintMs !== "number" || !Number.isFinite(hintMs) || hintMs <= 0) return current;
|
|
8332
|
+
return Math.min(current, startedAt + Math.max(hintMs, MIN_SERVER_DEADLINE_MS));
|
|
8333
|
+
}
|
|
8238
8334
|
async function resolvePlan(api, ctx, setup, timeoutMs) {
|
|
8239
|
-
const
|
|
8335
|
+
const startedAt = Date.now();
|
|
8336
|
+
let deadline = startedAt + timeoutMs;
|
|
8240
8337
|
let polls = 0;
|
|
8338
|
+
let finalPoll = false;
|
|
8241
8339
|
for (; ; ) {
|
|
8242
8340
|
const res = await api.resolve({
|
|
8243
8341
|
sha: ctx.sha,
|
|
@@ -8246,14 +8344,19 @@ async function resolvePlan(api, ctx, setup, timeoutMs) {
|
|
|
8246
8344
|
attempt: ctx.runAttempt,
|
|
8247
8345
|
setup: polls === 0 ? setup : void 0,
|
|
8248
8346
|
actions: { runId: ctx.runId, jobUrl: ctx.jobUrl, runnerOs: ctx.runnerOs },
|
|
8249
|
-
cliVersion: CLI_VERSION
|
|
8347
|
+
cliVersion: CLI_VERSION,
|
|
8348
|
+
// Tells the server this job is leaving, so a plan landing later re-dispatches CI
|
|
8349
|
+
// instead of stranding the run until it times out.
|
|
8350
|
+
...finalPoll ? { giveUp: true } : {}
|
|
8250
8351
|
});
|
|
8251
8352
|
polls += 1;
|
|
8252
8353
|
if (res.status !== "pending") return res;
|
|
8253
|
-
if (
|
|
8354
|
+
if (finalPoll) return res;
|
|
8355
|
+
deadline = serverDeadline(deadline, startedAt, res.giveUpAfterMs);
|
|
8254
8356
|
const wait = Math.min(Math.max(res.retryAfterMs || 5e3, 2e3), 3e4);
|
|
8255
8357
|
if (polls === 1 || polls % 6 === 0) log.info(`waiting for DevAsign to plan the tests${res.runId ? ` (run ${res.runId})` : ""}\u2026`);
|
|
8256
8358
|
await sleep3(wait);
|
|
8359
|
+
if (Date.now() >= deadline) finalPoll = true;
|
|
8257
8360
|
}
|
|
8258
8361
|
}
|
|
8259
8362
|
async function executePlan(plan, ws, opts) {
|
|
@@ -8284,6 +8387,7 @@ async function executePlan(plan, ws, opts) {
|
|
|
8284
8387
|
if (plan.playwright?.installBrowsers || !repoHasPlaywright(ws.root)) {
|
|
8285
8388
|
const inst = await ensureBrowsers(ws.root, ws);
|
|
8286
8389
|
if (!inst.ok) log.warn("Chromium install reported a failure; continuing \u2014 the run will tell us if the browser is missing");
|
|
8390
|
+
else setOutput("browsers", "true");
|
|
8287
8391
|
}
|
|
8288
8392
|
const generated = pw.filter((t) => t.origin === "generated");
|
|
8289
8393
|
const existing = pw.filter((t) => t.origin === "existing");
|
|
@@ -8304,10 +8408,27 @@ async function executePlan(plan, ws, opts) {
|
|
|
8304
8408
|
}
|
|
8305
8409
|
return { results, artifacts, doctor };
|
|
8306
8410
|
}
|
|
8307
|
-
|
|
8308
|
-
|
|
8309
|
-
const
|
|
8310
|
-
|
|
8411
|
+
var cell = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
8412
|
+
function summaryTable(plan, results) {
|
|
8413
|
+
const unverifiable = new Map((plan.unverifiable ?? []).map((u) => [u.criterionId, u]));
|
|
8414
|
+
const rows = plan.criteria.map((c) => {
|
|
8415
|
+
const mine = results.filter((r) => r.criterionIds.includes(c.id));
|
|
8416
|
+
const u = unverifiable.get(c.id);
|
|
8417
|
+
if (mine.length) {
|
|
8418
|
+
const tests = mine.map((r) => `${r.level} ${r.origin} \`${cell(r.test)}\``).join("<br>");
|
|
8419
|
+
const outcome = mine.map((r) => `${r.status} (${r.attempts.length} attempt${r.attempts.length === 1 ? "" : "s"})`).join("<br>");
|
|
8420
|
+
return `| ${c.id}. ${cell(c.text)} | ${tests} | ${outcome} | |`;
|
|
8421
|
+
}
|
|
8422
|
+
const note = u ? `${cell(u.reason)}${u.fixUrl ? ` \u2014 [configure app start](${u.fixUrl})` : ""}` : "no test planned";
|
|
8423
|
+
return `| ${c.id}. ${cell(c.text)} | \u2014 | unverifiable | ${note} |`;
|
|
8424
|
+
});
|
|
8425
|
+
return ["| Criterion | Test | Outcome | Notes |", "|---|---|---|---|", ...rows].join("\n");
|
|
8426
|
+
}
|
|
8427
|
+
function announceUnverifiable(plan) {
|
|
8428
|
+
for (const u of plan.unverifiable ?? []) {
|
|
8429
|
+
log.warn(`criterion ${u.criterionId} is unverifiable: ${u.reason}${u.fixUrl ? ` \u2014 fix: ${u.fixUrl}` : ""}`);
|
|
8430
|
+
}
|
|
8431
|
+
if (plan.tests.length === 0) log.warn("no tests could be planned for this PR; every criterion will be reported as unverifiable");
|
|
8311
8432
|
}
|
|
8312
8433
|
async function run(opts) {
|
|
8313
8434
|
const started = Date.now();
|
|
@@ -8351,7 +8472,10 @@ async function run(opts) {
|
|
|
8351
8472
|
plan = resolved.plan;
|
|
8352
8473
|
runId = resolved.runId;
|
|
8353
8474
|
log.info(`plan ${plan.planId}: ${plan.tests.length} test(s) for ${plan.criteria.length} criteria (run ${runId})`);
|
|
8475
|
+
setOutput("run-id", runId);
|
|
8354
8476
|
}
|
|
8477
|
+
announceUnverifiable(plan);
|
|
8478
|
+
const failOn = opts.failOn ?? plan.failOn ?? "never";
|
|
8355
8479
|
try {
|
|
8356
8480
|
const { results, artifacts, doctor } = await executePlan(plan, ws, { yml, testTimeoutMs: opts.testTimeoutMs, setup });
|
|
8357
8481
|
let finalResults = results;
|
|
@@ -8383,31 +8507,33 @@ async function run(opts) {
|
|
|
8383
8507
|
log.info("results uploaded \u2014 DevAsign is judging; the PR check run and comment will update");
|
|
8384
8508
|
}
|
|
8385
8509
|
const counts = finalResults.reduce((m, r) => (m[r.status] = (m[r.status] || 0) + 1, m), {});
|
|
8386
|
-
|
|
8510
|
+
const unplanned = plan.unverifiable?.length ?? 0;
|
|
8511
|
+
const outcome = `${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ") || "no tests ran"}${unplanned ? `, ${unplanned} unverifiable by plan` : ""}`;
|
|
8512
|
+
log.info(`outcome: ${outcome}${doctor ? ` \xB7 setup needs attention: ${doctor.code}` : ""}`);
|
|
8513
|
+
setOutput("outcome", outcome);
|
|
8387
8514
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
8388
8515
|
try {
|
|
8389
|
-
|
|
8516
|
+
appendFileSync2(process.env.GITHUB_STEP_SUMMARY, `## DevAsign verification
|
|
8390
8517
|
|
|
8391
8518
|
${doctor ? `> Setup needs attention: ${doctor.message}
|
|
8392
8519
|
|
|
8393
|
-
` : ""}${summaryTable(
|
|
8520
|
+
` : ""}${summaryTable(plan, finalResults)}
|
|
8394
8521
|
|
|
8395
8522
|
Verdicts are judged by DevAsign and posted on the PR (check run "DevAsign \xB7 Verify").
|
|
8396
8523
|
`);
|
|
8397
8524
|
} catch {
|
|
8398
8525
|
}
|
|
8399
8526
|
}
|
|
8400
|
-
if (
|
|
8527
|
+
if (failOn !== "never" && api) {
|
|
8401
8528
|
const deadline = Date.now() + 10 * 6e4;
|
|
8402
8529
|
while (Date.now() < deadline) {
|
|
8403
8530
|
const view = await api.getRun(runId);
|
|
8404
8531
|
if (view.terminal) {
|
|
8405
8532
|
const fails = view.run.verdicts.filter((v) => v.verdict === "fail");
|
|
8406
|
-
|
|
8407
|
-
|
|
8408
|
-
|
|
8409
|
-
|
|
8410
|
-
return 0;
|
|
8533
|
+
const unverified = failOn === "unverifiable" ? view.run.verdicts.filter((v) => v.verdict === "unverifiable") : [];
|
|
8534
|
+
for (const v of unverified) log.error(`criterion ${v.criterionId} could not be verified: ${v.reason}${v.fixUrl ? ` \u2014 fix: ${v.fixUrl}` : ""}`);
|
|
8535
|
+
if (fails.length) log.error(`${fails.length} criteria failed verification: ${fails.map((f) => f.criterionId).join(", ")}`);
|
|
8536
|
+
return fails.length || unverified.length ? 1 : 0;
|
|
8411
8537
|
}
|
|
8412
8538
|
await sleep3(5e3);
|
|
8413
8539
|
}
|
|
@@ -8431,17 +8557,24 @@ Usage: devasign-verify [run|detect|doctor] [options]
|
|
|
8431
8557
|
|
|
8432
8558
|
Options:
|
|
8433
8559
|
--api-url <url> DevAsign API origin (env DEVASIGN_API_URL)
|
|
8434
|
-
--fail-on never
|
|
8560
|
+
--fail-on <mode> never (default): always exit 0; verdict: fail the job on a
|
|
8561
|
+
failed criterion; unverifiable: also fail when a criterion
|
|
8562
|
+
could not be verified. Unset: the repo's DevAsign setting
|
|
8435
8563
|
--audience <aud> OIDC audience (default devasign)
|
|
8436
8564
|
--token <jwt> Use this token instead of the Actions OIDC token (local runs)
|
|
8437
8565
|
--pr <n> --sha <sha> Override the PR number / head sha (local runs)
|
|
8438
|
-
--resolve-timeout <s> Max seconds to wait for a plan (default
|
|
8566
|
+
--resolve-timeout <s> Max seconds to wait for a plan (default 180; DevAsign
|
|
8567
|
+
may shorten it and re-run this workflow when ready)
|
|
8439
8568
|
--test-timeout <s> Per test-file timeout in seconds (default 600)
|
|
8440
8569
|
--plan-file <path> Offline: run this plan JSON with no API
|
|
8441
8570
|
--results-out <path> Write the results JSON to this path
|
|
8442
8571
|
--keep Keep .devasign/tests and artifacts after the run
|
|
8443
8572
|
--cwd <dir> Repository checkout (default cwd)
|
|
8444
8573
|
`;
|
|
8574
|
+
var seconds = (raw, fallback) => {
|
|
8575
|
+
const n = Number(raw);
|
|
8576
|
+
return raw !== void 0 && Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
8577
|
+
};
|
|
8445
8578
|
async function main(argv = process.argv.slice(2)) {
|
|
8446
8579
|
const { values, positionals } = parseArgs({
|
|
8447
8580
|
args: argv,
|
|
@@ -8487,7 +8620,12 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
8487
8620
|
log.error("--api-url (or DEVASIGN_API_URL) is required");
|
|
8488
8621
|
return 2;
|
|
8489
8622
|
}
|
|
8490
|
-
const
|
|
8623
|
+
const failOnValue = values["fail-on"];
|
|
8624
|
+
if (failOnValue !== void 0 && !["never", "verdict", "unverifiable"].includes(failOnValue)) {
|
|
8625
|
+
log.error(`--fail-on must be never, verdict or unverifiable (got "${failOnValue}")`);
|
|
8626
|
+
return 2;
|
|
8627
|
+
}
|
|
8628
|
+
const failOn = failOnValue;
|
|
8491
8629
|
const tokenValue = values.token || process.env.DEVASIGN_TOKEN;
|
|
8492
8630
|
const token = tokenValue ? staticTokenSource(tokenValue) : actionsTokenSource(values.audience || process.env.DEVASIGN_OIDC_AUDIENCE || "devasign");
|
|
8493
8631
|
try {
|
|
@@ -8495,8 +8633,8 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
8495
8633
|
apiUrl,
|
|
8496
8634
|
token,
|
|
8497
8635
|
failOn,
|
|
8498
|
-
resolveTimeoutMs: (
|
|
8499
|
-
testTimeoutMs: (
|
|
8636
|
+
resolveTimeoutMs: seconds(values["resolve-timeout"], 180) * 1e3,
|
|
8637
|
+
testTimeoutMs: seconds(values["test-timeout"], 600) * 1e3,
|
|
8500
8638
|
keep: !!values.keep,
|
|
8501
8639
|
cwd,
|
|
8502
8640
|
pr: values.pr ? Number(values.pr) : void 0,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devasign/verify",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Runs DevAsign's generated acceptance tests inside your CI and reports per-criterion evidence.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@playwright/test": "
|
|
26
|
+
"@playwright/test": "1.55.0",
|
|
27
27
|
"tsx": "^4.22.4",
|
|
28
28
|
"yaml": "^2.9.0"
|
|
29
29
|
},
|