@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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/cli.js +172 -34
  3. 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.1";
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 child = spawn(opts.cmd, opts.args, { cwd: opts.cwd, env, stdio: ["ignore", "pipe", "pipe"] });
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
- child.kill("SIGKILL");
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
- "const projects = Array.isArray(b.projects) && b.projects.length",
8033
- " ? [b.projects.find((p: any) => /chrom/i.test(String(p?.name || ''))) ?? b.projects[0]]",
8034
- " : undefined;",
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 statuses = attempts.map((a) => a.status);
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 deadline = Date.now() + timeoutMs;
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 (Date.now() > deadline) return res;
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
- function summaryTable(results, plan) {
8308
- const byId = new Map(plan.criteria.map((c) => [c.id, c]));
8309
- const rows = results.map((r) => `| ${r.criterionIds.map((id) => `${id}. ${byId.get(id)?.text ?? ""}`).join("<br>")} | ${r.status} | ${r.level} ${r.origin} \`${r.test}\` | ${r.attempts.length} |`);
8310
- return ["| Criterion | Test outcome | Test | Attempts |", "|---|---|---|---|", ...rows].join("\n");
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
- log.info(`outcome: ${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ") || "no tests"}${doctor ? ` \xB7 setup needs attention: ${doctor.code}` : ""}`);
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
- appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## DevAsign verification
8516
+ appendFileSync2(process.env.GITHUB_STEP_SUMMARY, `## DevAsign verification
8390
8517
 
8391
8518
  ${doctor ? `> Setup needs attention: ${doctor.message}
8392
8519
 
8393
- ` : ""}${summaryTable(finalResults, plan)}
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 (opts.failOn === "verdict" && api) {
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
- if (fails.length) {
8407
- log.error(`${fails.length} criteria failed verification: ${fails.map((f) => f.criterionId).join(", ")}`);
8408
- return 1;
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|verdict Fail the job on a failed criterion (default 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 600)
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 failOn = values["fail-on"] === "verdict" ? "verdict" : "never";
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: (Number(values["resolve-timeout"]) || 600) * 1e3,
8499
- testTimeoutMs: (Number(values["test-timeout"]) || 600) * 1e3,
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.1",
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": "^1.55.0",
26
+ "@playwright/test": "1.55.0",
27
27
  "tsx": "^4.22.4",
28
28
  "yaml": "^2.9.0"
29
29
  },