@datarails-ai/dr-appspace-cli 0.12.31 → 0.12.33

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 +41 -0
  2. package/dist/cli.js +791 -66
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -325,6 +325,47 @@ lives in dr-assets-service, attached to the agent by id — see the repo's
325
325
  command calls a model: the builders write `contract.json` themselves and run
326
326
  `app-space compile`.
327
327
 
328
+ ### A picture of every page, as every role: `test --screenshot`
329
+
330
+ `app-space test --screenshot` draws, after the usual checks, every page of the
331
+ pushed package once per declared role in headless Google Chrome, and writes
332
+ `<out>/<page>.<role>.png` plus an `index.json` that a judge can read (page,
333
+ role, file, size, which handlers the page called, which had no fixture). It
334
+ judges nothing yet; the `look-*` findings come later.
335
+
336
+ | Flag | What it does | Default |
337
+ | --- | --- | --- |
338
+ | `--screenshot` | take the pictures after the checks | off |
339
+ | `--out <dir>` | where the PNGs and `index.json` go | `<dir>/.screenshots` |
340
+ | `--role <name>` | only this declared role (repeatable, or `a,b`) | every declared role (`default` for an app with none) |
341
+ | `--width <px>` / `--height <px>` | the window | `1400` × `900` |
342
+ | `--full` | grow the window to the page's full height (one extra load per picture) | off |
343
+ | `--shelf <path>` | serve `vendor/` and `ui-kit/` from this dr-app-space checkout | the server the app is pushed to |
344
+ | `--screenshot-budget <ms>` | the whole phase's ceiling; each picture is capped at 60 s | `180000` |
345
+
346
+ ```
347
+ dr-appspace app-space test ./my-app --skip-handlers --interact never --screenshot -s DEV-1
348
+ dr-appspace app-space test ./my-app --screenshot --role approver --full --shelf ~/dr-app-space -s DEV-1
349
+ ```
350
+
351
+ The pages load from a stub on `127.0.0.1` (an ephemeral port), never from
352
+ the app's server, so taking pictures writes no row. The stub answers
353
+ `/access` as each role (the manifest's page and handler gates, under the
354
+ server's own answer for that role), each handler with its `tests.json`
355
+ fixture (the first `expect`, else a value built from the first `shape`),
356
+ `[]` when no fixture names it, and 403 when the role's gate refuses the
357
+ call. A page a role may not open is listed as skipped, not drawn. The shelf
358
+ comes from the server the app is pushed to (its public `/vendor/*` and
359
+ `/ui-kit/*`, fetched once per run). Use `--shelf` with a checkout to work
360
+ offline.
361
+
362
+ Chrome is found through `CHROME_PATH`, then `google-chrome`, `chromium` or
363
+ `chrome` on `PATH`, then the macOS app bundle and the usual Linux paths. With
364
+ none, the command exits 2 before anything runs. No browser is ever
365
+ downloaded. Each Chrome gets its own temporary profile, which is removed
366
+ afterwards, and its process group is killed once the file is written,
367
+ because Chrome 154 does not always exit after `--screenshot`.
368
+
328
369
  ## Development
329
370
 
330
371
  This package lives inside the dr-app-space repo but is independent of the
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { fileURLToPath as fileURLToPath6 } from "url";
13
13
  // package.json
14
14
  var package_default = {
15
15
  name: "@datarails-ai/dr-appspace-cli",
16
- version: "0.12.31",
16
+ version: "0.12.33",
17
17
  description: "Datarails App Space CLI \u2014 build, try, push and publish App Space apps",
18
18
  type: "module",
19
19
  engines: {
@@ -57,7 +57,7 @@ var package_default = {
57
57
  };
58
58
 
59
59
  // src/buildInfo.ts
60
- var BUILD_TIME = "2026-10-25T12:56:17.438Z";
60
+ var BUILD_TIME = "2026-10-26T12:07:09.192Z";
61
61
  function formatBuildTime(iso) {
62
62
  if (iso === "") return "";
63
63
  const d = new Date(iso);
@@ -776,12 +776,12 @@ async function requestDeviceCode(host, options) {
776
776
  interval: typeof obj4.interval === "number" ? obj4.interval : 0
777
777
  };
778
778
  }
779
- var defaultSleep = (ms, signal) => new Promise((resolve15, reject) => {
779
+ var defaultSleep = (ms, signal) => new Promise((resolve17, reject) => {
780
780
  if (signal?.aborted) {
781
781
  reject(signal.reason ?? new Error("aborted"));
782
782
  return;
783
783
  }
784
- const t = setTimeout(resolve15, ms);
784
+ const t = setTimeout(resolve17, ms);
785
785
  signal?.addEventListener(
786
786
  "abort",
787
787
  () => {
@@ -827,7 +827,7 @@ async function pollForToken(host, deviceCode, options = {}) {
827
827
  const interval = options.interval ?? 2e3;
828
828
  const maxWait = options.maxWait ?? 6e5;
829
829
  const now = options.now ?? Date.now;
830
- const sleep2 = options.sleep ?? defaultSleep;
830
+ const sleep3 = options.sleep ?? defaultSleep;
831
831
  const signal = options.signal;
832
832
  const base = host.replace(/\/+$/, "");
833
833
  const deadline = now() + maxWait;
@@ -859,7 +859,7 @@ async function pollForToken(host, deviceCode, options = {}) {
859
859
  }
860
860
  const errCode = tryParseErrorCode(text3);
861
861
  if (errCode === "authorization_pending" || errCode === "slow_down") {
862
- await sleep2(interval, signal);
862
+ await sleep3(interval, signal);
863
863
  continue;
864
864
  }
865
865
  if (errCode) {
@@ -1407,19 +1407,19 @@ async function openBrowser(url) {
1407
1407
 
1408
1408
  // src/commands/_serverPicker.ts
1409
1409
  async function readLine(stdin) {
1410
- return new Promise((resolve15, reject) => {
1410
+ return new Promise((resolve17, reject) => {
1411
1411
  let data = "";
1412
1412
  const onData = (chunk) => {
1413
1413
  data += chunk.toString();
1414
1414
  const i = data.indexOf("\n");
1415
1415
  if (i >= 0) {
1416
1416
  cleanup();
1417
- resolve15(data.slice(0, i + 1));
1417
+ resolve17(data.slice(0, i + 1));
1418
1418
  }
1419
1419
  };
1420
1420
  const onEnd = () => {
1421
1421
  cleanup();
1422
- resolve15(data);
1422
+ resolve17(data);
1423
1423
  };
1424
1424
  const onError = (err) => {
1425
1425
  cleanup();
@@ -8636,10 +8636,10 @@ function pageCode(html, path3, modules, pageSource) {
8636
8636
  continue;
8637
8637
  }
8638
8638
  if (/^(?:[a-z]+:)?\/\//i.test(src) || src.startsWith("/") || src.startsWith("../")) continue;
8639
- const relative3 = src.replace(/^\.\//, "");
8640
- if (modules.has(`${path3}|${relative3}`)) continue;
8639
+ const relative4 = src.replace(/^\.\//, "");
8640
+ if (modules.has(`${path3}|${relative4}`)) continue;
8641
8641
  const dir = path3.includes("/") ? path3.slice(0, path3.lastIndexOf("/") + 1) : "";
8642
- const body = pageSource(`${dir}${relative3}`);
8642
+ const body = pageSource(`${dir}${relative4}`);
8643
8643
  if (typeof body !== "string") return null;
8644
8644
  out.push(body);
8645
8645
  }
@@ -11859,7 +11859,7 @@ var CONTROLS = "dr-components-controls@1.2.0";
11859
11859
  var DASHBOARD_RUNTIME = "dr-app-runtime@1.13.1";
11860
11860
  var DASHBOARD_WIDGETS = "dr-app-widgets@1.6.0";
11861
11861
  var DASHBOARD_BAR = "dr-app-dashboard@1.0.0";
11862
- var ASSISTANT = "dr-insights@1.4.0";
11862
+ var ASSISTANT = "dr-insights@1.4.1";
11863
11863
  var MARKDOWN = "dr-md@1.0.0";
11864
11864
  var PART_FILES = {
11865
11865
  "dr-components": { css: "components.css", js: "components.js" },
@@ -15696,9 +15696,9 @@ function whereProblems(where2, table, pointer2, who, { platform = false, noColum
15696
15696
  const problems = [];
15697
15697
  const say = (key, message, code = "app_model_mismatch") => problems.push({ code, pointer: pointer2(key), message: `${who} ${message}` });
15698
15698
  for (const key of Object.keys(where2).sort()) {
15699
- const sep6 = key.indexOf("__");
15700
- const column = sep6 < 0 ? key : key.slice(0, sep6);
15701
- const op = sep6 < 0 ? "eq" : key.slice(sep6 + 2);
15699
+ const sep7 = key.indexOf("__");
15700
+ const column = sep7 < 0 ? key : key.slice(0, sep7);
15701
+ const op = sep7 < 0 ? "eq" : key.slice(sep7 + 2);
15702
15702
  const value = where2[key];
15703
15703
  if (!WHERE_OPS.includes(op)) {
15704
15704
  say(key, `filters with "${key}", and "${op}" is no operator a where knows: ${WHERE_OPS.join(", ")}`);
@@ -21590,7 +21590,7 @@ var LiveContractMissing = class extends Error {
21590
21590
  };
21591
21591
  async function pullInto(client, appId, dir, {
21592
21592
  onWait,
21593
- sleep: sleep2 = (ms) => new Promise((done) => setTimeout(done, ms))
21593
+ sleep: sleep3 = (ms) => new Promise((done) => setTimeout(done, ms))
21594
21594
  } = {}) {
21595
21595
  for (let pull = 1; ; pull += 1) {
21596
21596
  let pkg;
@@ -21612,7 +21612,7 @@ async function pullInto(client, appId, dir, {
21612
21612
  if (pull >= contractWait.pulls) throw new LiveContractMissing(appId, pkg.revision, pull, read.missing);
21613
21613
  const ms = contractWaitMs(pull);
21614
21614
  onWait?.(pull, ms, read.missing);
21615
- await sleep2(ms);
21615
+ await sleep3(ms);
21616
21616
  }
21617
21617
  }
21618
21618
  async function runGet(appId, opts) {
@@ -26576,10 +26576,10 @@ function pageCode2(html, path3, modules, pageSource) {
26576
26576
  continue;
26577
26577
  }
26578
26578
  if (/^(?:[a-z]+:)?\/\//i.test(src) || src.startsWith("/") || src.startsWith("../")) continue;
26579
- const relative3 = src.replace(/^\.\//, "");
26580
- if (modules.has(`${path3}|${relative3}`)) continue;
26579
+ const relative4 = src.replace(/^\.\//, "");
26580
+ if (modules.has(`${path3}|${relative4}`)) continue;
26581
26581
  const dir = path3.includes("/") ? path3.slice(0, path3.lastIndexOf("/") + 1) : "";
26582
- const body = pageSource(`${dir}${relative3}`);
26582
+ const body = pageSource(`${dir}${relative4}`);
26583
26583
  if (typeof body !== "string") return null;
26584
26584
  out.push(body);
26585
26585
  }
@@ -28986,7 +28986,7 @@ var kit_default = {
28986
28986
  description: "Templates a dashboard app is built from, and the checks the Insights Builder runs on every build (live data only, widgets only, an Ask button per widget).",
28987
28987
  checks: "checks/run.mjs",
28988
28988
  templates: "templates/",
28989
- assistant: "dr-insights@1.4.0",
28989
+ assistant: "dr-insights@1.4.1",
28990
28990
  charts: "echarts@5.6.0",
28991
28991
  fmt: "dr-fmt@2.1.0",
28992
28992
  markdown: "dr-md@1.0.0",
@@ -30351,7 +30351,7 @@ async function runShipOntoLive(dir, opts) {
30351
30351
  return { code, lost: code === 3 ? await lostRaceOf(dir) : null, creating: pulled === null };
30352
30352
  }, opts);
30353
30353
  }
30354
- async function landWithRetries(tryOnce, opts, sleep2 = (ms) => new Promise((done) => setTimeout(done, ms))) {
30354
+ async function landWithRetries(tryOnce, opts, sleep3 = (ms) => new Promise((done) => setTimeout(done, ms))) {
30355
30355
  let createGuarded = true;
30356
30356
  for (let attempt = 1; attempt <= ONTO_LIVE_ATTEMPTS; attempt += 1) {
30357
30357
  const { code, lost, creating } = await tryOnce(createGuarded);
@@ -30372,7 +30372,7 @@ async function landWithRetries(tryOnce, opts, sleep2 = (ms) => new Promise((done
30372
30372
  `
30373
30373
  );
30374
30374
  }
30375
- await sleep2(ontoLiveBackoff(attempt));
30375
+ await sleep3(ontoLiveBackoff(attempt));
30376
30376
  }
30377
30377
  process.stderr.write(
30378
30378
  `ship --onto-live: ${String(ONTO_LIVE_ATTEMPTS)} attempts, and another landing got there first every time. Nothing of this pass is live; run the same command again.
@@ -30865,8 +30865,8 @@ function registerLogsCommand(parent) {
30865
30865
  }
30866
30866
 
30867
30867
  // src/commands/appspace/test.ts
30868
- import { readFile as readFile12 } from "fs/promises";
30869
- import { join as join17 } from "path";
30868
+ import { readFile as readFile13 } from "fs/promises";
30869
+ import { join as join18, resolve as resolve16 } from "path";
30870
30870
 
30871
30871
  // src/commands/appspace/_files.ts
30872
30872
  import { readFile as readFile11, realpath } from "fs/promises";
@@ -31431,15 +31431,15 @@ function planInteractions(components, model, page, uidOf) {
31431
31431
  for (const child of components) {
31432
31432
  if (child.props.field === void 0 || child.props.field === "") continue;
31433
31433
  if (!FORM_CONTROL_TAGS.has(child.tag)) continue;
31434
- let inside = false;
31434
+ let inside2 = false;
31435
31435
  let hops = 0;
31436
31436
  for (let up = child.parent === null ? void 0 : byIndex.get(child.parent); up !== void 0 && hops <= components.length; up = up.parent === null ? void 0 : byIndex.get(up.parent), hops += 1) {
31437
31437
  if (up.n === component.n) {
31438
- inside = true;
31438
+ inside2 = true;
31439
31439
  break;
31440
31440
  }
31441
31441
  }
31442
- if (!inside) continue;
31442
+ if (!inside2) continue;
31443
31443
  const field = child.props.field;
31444
31444
  const column = columnOf(model, component.props.entity, field);
31445
31445
  const control = controlFor(column);
@@ -32372,13 +32372,13 @@ var defaultBlobReader = async (blob) => {
32372
32372
  function blobReaderFor(window) {
32373
32373
  const ctor = window?.FileReader;
32374
32374
  if (typeof ctor !== "function") return defaultBlobReader;
32375
- return (blob) => new Promise((resolve15, reject) => {
32375
+ return (blob) => new Promise((resolve17, reject) => {
32376
32376
  const reader = new ctor();
32377
32377
  reader.onload = () => {
32378
32378
  const result = reader.result;
32379
- if (result === null || result === void 0) resolve15(Buffer.alloc(0));
32380
- else if (typeof result === "string") resolve15(Buffer.from(result, "utf8"));
32381
- else resolve15(Buffer.from(result));
32379
+ if (result === null || result === void 0) resolve17(Buffer.alloc(0));
32380
+ else if (typeof result === "string") resolve17(Buffer.from(result, "utf8"));
32381
+ else resolve17(Buffer.from(result));
32382
32382
  };
32383
32383
  reader.onerror = () => reject(new Error("the attached file could not be read"));
32384
32384
  reader.onabort = () => reject(new Error("reading the attached file was aborted"));
@@ -32665,7 +32665,7 @@ async function loadJsdom() {
32665
32665
  );
32666
32666
  }
32667
32667
  }
32668
- var sleep = (ms) => new Promise((resolve15) => setTimeout(resolve15, ms));
32668
+ var sleep = (ms) => new Promise((resolve17) => setTimeout(resolve17, ms));
32669
32669
  var DRAW_GRACE_MS = 3e3;
32670
32670
  async function runUiSmoke(opts) {
32671
32671
  const timeoutMs = opts.timeoutMs ?? 15e3;
@@ -33168,6 +33168,649 @@ async function runUiSmoke(opts) {
33168
33168
  };
33169
33169
  }
33170
33170
 
33171
+ // src/commands/appspace/_screenshot.ts
33172
+ import { spawn } from "child_process";
33173
+ import { existsSync as existsSync3, statSync as statSync3 } from "fs";
33174
+ import { mkdir as mkdir8, mkdtemp as mkdtemp4, readFile as readFile12, readdir as readdir3, rm as rm5, stat as stat2, writeFile as writeFile9 } from "fs/promises";
33175
+ import { createServer } from "http";
33176
+ import { tmpdir as tmpdir5 } from "os";
33177
+ import { delimiter, extname as extname2, join as join17, relative as relative3, resolve as resolve15, sep as sep6 } from "path";
33178
+ var SHOT_TIMEOUT_MS = 6e4;
33179
+ var SHOT_BUDGET_MS = 18e4;
33180
+ var SHOT_CONCURRENCY = 4;
33181
+ var VIRTUAL_TIME_MS = 8e3;
33182
+ var ATTEMPT_MS = 25e3;
33183
+ var MEASURE_MS = 15e3;
33184
+ var FULL_HEIGHT_CAP = 8e3;
33185
+ var NO_ROLE = "default";
33186
+ var CHROME_MISSING = "app-space test --screenshot needs Google Chrome: install Google Chrome or set CHROME_PATH to a Chrome or Chromium binary. Nothing is downloaded.";
33187
+ var CHROME_APP_PATHS = [
33188
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
33189
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
33190
+ "/usr/bin/google-chrome",
33191
+ "/usr/bin/google-chrome-stable",
33192
+ "/usr/bin/chromium",
33193
+ "/usr/bin/chromium-browser"
33194
+ ];
33195
+ var CHROME_NAMES = ["google-chrome", "chromium", "chrome"];
33196
+ function isFile(path3) {
33197
+ try {
33198
+ return statSync3(path3).isFile();
33199
+ } catch {
33200
+ return false;
33201
+ }
33202
+ }
33203
+ function findChrome(env = process.env, exists = isFile, appPaths = CHROME_APP_PATHS) {
33204
+ const pinned = env["CHROME_PATH"]?.trim();
33205
+ if (pinned) return pinned;
33206
+ const dirs = (env["PATH"] ?? "").split(delimiter).filter((dir) => dir !== "");
33207
+ for (const name of CHROME_NAMES) {
33208
+ for (const dir of dirs) {
33209
+ const path3 = join17(dir, name);
33210
+ if (exists(path3)) return path3;
33211
+ }
33212
+ }
33213
+ return appPaths.find((path3) => exists(path3)) ?? null;
33214
+ }
33215
+ function rolesOf6(manifest) {
33216
+ if (!Array.isArray(manifest?.roles)) return [];
33217
+ return manifest.roles.filter(
33218
+ (one) => typeof one === "object" && one !== null && typeof one.name === "string"
33219
+ );
33220
+ }
33221
+ function pageGates(manifest) {
33222
+ const out = /* @__PURE__ */ new Map();
33223
+ if (!Array.isArray(manifest?.pages)) return out;
33224
+ for (const page of manifest.pages) {
33225
+ if (typeof page !== "object" || page === null) continue;
33226
+ const { path: path3, roles } = page;
33227
+ if (typeof path3 !== "string" || !Array.isArray(roles)) continue;
33228
+ const names = roles.filter((r) => typeof r === "string");
33229
+ if (names.length > 0) out.set(path3, names);
33230
+ }
33231
+ return out;
33232
+ }
33233
+ function handlerGates(manifest) {
33234
+ const out = /* @__PURE__ */ new Map();
33235
+ const raw = manifest?.handlers;
33236
+ if (typeof raw !== "object" || raw === null) return out;
33237
+ for (const [name, row] of Object.entries(raw)) {
33238
+ const gate = typeof row === "object" && row !== null ? row.requiresRole : void 0;
33239
+ out.set(name, Array.isArray(gate) ? gate.filter((r) => typeof r === "string") : null);
33240
+ }
33241
+ return out;
33242
+ }
33243
+ function shotRoles(manifest, only = []) {
33244
+ const declared = rolesOf6(manifest).map((role) => role.name);
33245
+ const all = declared.length === 0 ? [NO_ROLE] : declared;
33246
+ if (only.length === 0) return all;
33247
+ const unknown = only.filter((role) => !all.includes(role));
33248
+ if (unknown.length > 0) {
33249
+ throw new Error(
33250
+ `--role ${unknown.join(", ")}: this app ${declared.length === 0 ? 'declares no roles (its one render is "default")' : `declares ${declared.join(", ")}`}.`
33251
+ );
33252
+ }
33253
+ return all.filter((role) => only.includes(role));
33254
+ }
33255
+ function seesOf(roles, role) {
33256
+ const declared = new Set(roles.map((r) => r.name));
33257
+ const raw = roles.find((r) => r.name === role)?.sees;
33258
+ if (!Array.isArray(raw)) return [];
33259
+ return raw.filter((s) => typeof s === "string" && declared.has(s) && s !== role);
33260
+ }
33261
+ function maySeePage(manifest, role, entry) {
33262
+ const roles = rolesOf6(manifest);
33263
+ const gate = pageGates(manifest).get(`ui/${entry}`);
33264
+ if (gate === void 0 || roles.length === 0) return true;
33265
+ const visible = /* @__PURE__ */ new Set([role, ...seesOf(roles, role)]);
33266
+ return gate.some((r) => visible.has(r));
33267
+ }
33268
+ function accessFor(manifest, role, overlay, sources = []) {
33269
+ const roles = rolesOf6(manifest);
33270
+ const names = roles.map((r) => r.name);
33271
+ const held = names.length === 0 ? null : role;
33272
+ const visible = new Set(held === null ? [] : [held, ...seesOf(roles, held)]);
33273
+ const allowed = {};
33274
+ for (const name of names) allowed[name] = visible.has(name);
33275
+ const gates = pageGates(manifest);
33276
+ for (const [path3, gate] of gates) {
33277
+ allowed[gate.join(" ")] = maySeePage(manifest, role, path3.replace(/^ui\//, ""));
33278
+ }
33279
+ for (const source of sources) allowed[source] = true;
33280
+ for (const [handler, gate] of handlerGates(manifest)) {
33281
+ allowed[handler] = gate === null || gate.length === 0 || held === null || gate.includes(held);
33282
+ }
33283
+ Object.assign(allowed, overlay ?? {});
33284
+ const payload = {
33285
+ role: held,
33286
+ asRole: null,
33287
+ defaultRole: roles.find((r) => r.default === true)?.name ?? null,
33288
+ roles: names,
33289
+ allowed,
33290
+ user: { email: "screenshot@app-space.test" }
33291
+ };
33292
+ if (gates.size > 0 || roles.some((r) => typeof r.home === "string")) {
33293
+ payload.pages = Object.fromEntries(
33294
+ [...gates.keys()].sort().map((path3) => [path3, maySeePage(manifest, role, path3.replace(/^ui\//, ""))])
33295
+ );
33296
+ const home = roles.find((r) => r.name === held)?.home;
33297
+ payload.home = typeof home === "string" && /^ui\/[A-Za-z0-9_-]+\.html$/.test(home) ? home : null;
33298
+ }
33299
+ return payload;
33300
+ }
33301
+ function valueOfShape(shape) {
33302
+ if (typeof shape === "string") {
33303
+ const want = shape.endsWith("?") ? shape.slice(0, -1) : shape;
33304
+ switch (want) {
33305
+ case "string":
33306
+ return "";
33307
+ case "number":
33308
+ return 0;
33309
+ case "boolean":
33310
+ return false;
33311
+ case "object":
33312
+ return {};
33313
+ case "array":
33314
+ return [];
33315
+ default:
33316
+ return null;
33317
+ }
33318
+ }
33319
+ if (Array.isArray(shape)) return [];
33320
+ if (typeof shape === "object" && shape !== null) {
33321
+ return Object.fromEntries(Object.entries(shape).map(([key, sub]) => [key, valueOfShape(sub)]));
33322
+ }
33323
+ return null;
33324
+ }
33325
+ function fixtureAnswers(cases) {
33326
+ const out = /* @__PURE__ */ new Map();
33327
+ for (const kase of cases) {
33328
+ if (kase.error !== void 0 || kase.expect === void 0) continue;
33329
+ if (!out.has(kase.handler)) out.set(kase.handler, { body: kase.expect, from: "expect" });
33330
+ }
33331
+ for (const kase of cases) {
33332
+ if (kase.error !== void 0 || kase.shape === void 0 || out.has(kase.handler)) continue;
33333
+ out.set(kase.handler, { body: valueOfShape(kase.shape), from: "shape" });
33334
+ }
33335
+ return out;
33336
+ }
33337
+ var TYPES = {
33338
+ ".html": "text/html; charset=utf-8",
33339
+ ".htm": "text/html; charset=utf-8",
33340
+ ".js": "text/javascript; charset=utf-8",
33341
+ ".mjs": "text/javascript; charset=utf-8",
33342
+ ".css": "text/css; charset=utf-8",
33343
+ ".json": "application/json",
33344
+ ".svg": "image/svg+xml",
33345
+ ".png": "image/png",
33346
+ ".jpg": "image/jpeg",
33347
+ ".jpeg": "image/jpeg",
33348
+ ".gif": "image/gif",
33349
+ ".webp": "image/webp",
33350
+ ".ico": "image/x-icon",
33351
+ ".woff": "font/woff",
33352
+ ".woff2": "font/woff2",
33353
+ ".ttf": "font/ttf",
33354
+ ".otf": "font/otf",
33355
+ ".map": "application/json"
33356
+ };
33357
+ function contentType(path3) {
33358
+ return TYPES[extname2(path3).toLowerCase()] ?? "application/octet-stream";
33359
+ }
33360
+ function inside(root, rel) {
33361
+ const base = resolve15(root);
33362
+ const full = resolve15(base, rel);
33363
+ return full === base || !full.startsWith(base + sep6) ? null : full;
33364
+ }
33365
+ var Shelf = class {
33366
+ constructor(source, fetchImpl = fetch) {
33367
+ this.source = source;
33368
+ this.fetchImpl = fetchImpl;
33369
+ }
33370
+ source;
33371
+ fetchImpl;
33372
+ cache = /* @__PURE__ */ new Map();
33373
+ misses = /* @__PURE__ */ new Set();
33374
+ get(path3) {
33375
+ let hit = this.cache.get(path3);
33376
+ if (hit === void 0) {
33377
+ hit = this.load(path3).then((served) => {
33378
+ if (served.status !== 200) this.misses.add(path3);
33379
+ return served;
33380
+ });
33381
+ this.cache.set(path3, hit);
33382
+ }
33383
+ return hit;
33384
+ }
33385
+ async load(path3) {
33386
+ const missing = { status: 404, type: "text/plain", body: Buffer.from("not on the shelf") };
33387
+ if (this.source.kind === "server") {
33388
+ try {
33389
+ const res = await this.fetchImpl(`${this.source.baseUrl.replace(/\/+$/, "")}/${path3}`, {
33390
+ signal: AbortSignal.timeout(3e4)
33391
+ });
33392
+ if (!res.ok) return { ...missing, status: res.status };
33393
+ return {
33394
+ status: 200,
33395
+ type: res.headers.get("content-type") ?? contentType(path3),
33396
+ body: Buffer.from(await res.arrayBuffer())
33397
+ };
33398
+ } catch {
33399
+ return { ...missing, status: 502 };
33400
+ }
33401
+ }
33402
+ const candidates = path3.startsWith("ui-kit/") ? [path3, `appspace/ui_kit/${path3.slice("ui-kit/".length)}`] : [path3];
33403
+ for (const rel of candidates) {
33404
+ const full = inside(this.source.root, rel);
33405
+ if (full === null) continue;
33406
+ try {
33407
+ return { status: 200, type: contentType(full), body: await readFile12(full) };
33408
+ } catch {
33409
+ }
33410
+ }
33411
+ return missing;
33412
+ }
33413
+ };
33414
+ var MEASURE_SCRIPT = '<script>addEventListener("load",function(){setTimeout(function(){var d=document.documentElement,b=document.body||d,extra=0;document.querySelectorAll("*").forEach(function(e){var s=getComputedStyle(e).overflowY;if((s==="auto"||s==="scroll")&&e.scrollHeight>e.clientHeight)extra=Math.max(extra,e.scrollHeight-e.clientHeight)});var h=Math.max(d.scrollHeight,b.scrollHeight,innerHeight+extra);var m=document.createElement("meta");m.id="dr-shot-height";m.content=String(h);document.head.appendChild(m)},3000)})</script>';
33415
+ function injectMeasure(html) {
33416
+ const head = /<head\b[^>]*>/i.exec(html);
33417
+ if (head === null) return MEASURE_SCRIPT + html;
33418
+ return html.slice(0, head.index + head[0].length) + MEASURE_SCRIPT + html.slice(head.index + head[0].length);
33419
+ }
33420
+ function send(res, status, type, body) {
33421
+ res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store" });
33422
+ res.end(body);
33423
+ }
33424
+ function sendJson(res, status, value) {
33425
+ send(res, status, "application/json", JSON.stringify(value));
33426
+ }
33427
+ function drain(req) {
33428
+ return new Promise((done) => {
33429
+ req.on("data", () => void 0);
33430
+ req.on("end", () => done());
33431
+ req.on("error", () => done());
33432
+ });
33433
+ }
33434
+ async function startStubServer(opts) {
33435
+ const shots = /* @__PURE__ */ new Map();
33436
+ const logs = /* @__PURE__ */ new Map();
33437
+ const logOf = (id) => {
33438
+ let log = logs.get(id);
33439
+ if (log === void 0) {
33440
+ log = { called: [], answered: [], noFixture: [], refused: [] };
33441
+ logs.set(id, log);
33442
+ }
33443
+ return log;
33444
+ };
33445
+ const note = (list3, name) => {
33446
+ if (!list3.includes(name)) list3.push(name);
33447
+ };
33448
+ const appPrefix = /^apps\/[^/]+\//;
33449
+ const handle = async (req, res) => {
33450
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
33451
+ let path3;
33452
+ try {
33453
+ path3 = decodeURIComponent(url.pathname);
33454
+ } catch {
33455
+ return send(res, 400, "text/plain", "bad path");
33456
+ }
33457
+ const match = /^\/s\/(\d+)\/(.*)$/.exec(path3);
33458
+ const id = match === null ? -1 : Number(match[1]);
33459
+ const rest = match === null ? path3.replace(/^\/+/, "") : match[2] ?? "";
33460
+ const shot = shots.get(id);
33461
+ if (req.method === "GET" && (rest.startsWith("vendor/") || rest.startsWith("ui-kit/"))) {
33462
+ const served = await opts.shelf.get(rest);
33463
+ return send(res, served.status, served.type, served.body);
33464
+ }
33465
+ const prefix = appPrefix.exec(rest);
33466
+ if (shot === void 0 || prefix === null) {
33467
+ await drain(req);
33468
+ return send(res, 404, "text/plain", "not found");
33469
+ }
33470
+ const inApp = rest.slice(prefix[0].length);
33471
+ if (req.method === "GET" && inApp === "access") {
33472
+ return sendJson(
33473
+ res,
33474
+ 200,
33475
+ accessFor(opts.manifest, shot.role, opts.allowedByRole?.[shot.role], opts.sources ?? [])
33476
+ );
33477
+ }
33478
+ if (req.method === "GET" && inApp.startsWith("ui/")) {
33479
+ const entry = inApp.slice("ui/".length);
33480
+ const body = Object.prototype.hasOwnProperty.call(opts.files, entry) ? opts.files[entry] : void 0;
33481
+ if (body === void 0) return send(res, 404, "text/plain", "no such UI file");
33482
+ const type = contentType(entry);
33483
+ if (shot.measure === true && type.startsWith("text/html")) {
33484
+ return send(res, 200, type, injectMeasure(body.toString()));
33485
+ }
33486
+ return send(res, 200, type, body);
33487
+ }
33488
+ if (req.method === "POST" && /^[^/]+$/.test(inApp)) {
33489
+ await drain(req);
33490
+ if (inApp === "uploads") return sendJson(res, 200, { upload: "screenshot-stub" });
33491
+ const log = logOf(id);
33492
+ note(log.called, inApp);
33493
+ const access = accessFor(opts.manifest, shot.role, opts.allowedByRole?.[shot.role], opts.sources ?? []);
33494
+ if (access.allowed[inApp] === false) {
33495
+ note(log.refused, inApp);
33496
+ return sendJson(res, 403, { detail: `role ${shot.role} may not call ${inApp}` });
33497
+ }
33498
+ const answer = opts.answers.get(inApp);
33499
+ if (answer === void 0) {
33500
+ note(log.noFixture, inApp);
33501
+ return sendJson(res, 200, []);
33502
+ }
33503
+ note(log.answered, inApp);
33504
+ return sendJson(res, 200, answer.body);
33505
+ }
33506
+ await drain(req);
33507
+ return send(res, 404, "text/plain", "not found");
33508
+ };
33509
+ const server = createServer((req, res) => {
33510
+ handle(req, res).catch(() => {
33511
+ if (!res.headersSent) send(res, 500, "text/plain", "stub error");
33512
+ else res.end();
33513
+ });
33514
+ });
33515
+ await new Promise((ok, fail) => {
33516
+ server.once("error", fail);
33517
+ server.listen(0, "127.0.0.1", () => ok());
33518
+ });
33519
+ const port = server.address().port;
33520
+ return {
33521
+ port,
33522
+ urlFor: (id, entry) => `http://127.0.0.1:${String(port)}/s/${String(id)}/apps/${encodeURIComponent(opts.appId)}/ui/${entry.split("/").map(encodeURIComponent).join("/")}`,
33523
+ register: (id, shot) => {
33524
+ shots.set(id, shot);
33525
+ },
33526
+ calls: (id) => {
33527
+ const log = logOf(id);
33528
+ return { called: [...log.called], answered: [...log.answered], noFixture: [...log.noFixture], refused: [...log.refused] };
33529
+ },
33530
+ close: () => new Promise((done) => {
33531
+ server.closeAllConnections?.();
33532
+ server.close(() => done());
33533
+ })
33534
+ };
33535
+ }
33536
+ function killGroup(child) {
33537
+ if (child.pid === void 0) return;
33538
+ try {
33539
+ process.kill(-child.pid, "SIGKILL");
33540
+ } catch {
33541
+ try {
33542
+ child.kill("SIGKILL");
33543
+ } catch {
33544
+ }
33545
+ }
33546
+ }
33547
+ var sleep2 = (ms) => new Promise((done) => setTimeout(done, ms));
33548
+ async function runChrome(run) {
33549
+ const profile = await mkdtemp4(join17(tmpdir5(), "dr-appspace-shot-"));
33550
+ const args = [
33551
+ "--headless=new",
33552
+ "--disable-gpu",
33553
+ "--hide-scrollbars",
33554
+ "--no-first-run",
33555
+ "--no-default-browser-check",
33556
+ "--disable-extensions",
33557
+ "--disable-background-networking",
33558
+ "--disable-sync",
33559
+ "--mute-audio",
33560
+ `--user-data-dir=${profile}`,
33561
+ `--window-size=${String(run.width)},${String(run.height)}`,
33562
+ `--virtual-time-budget=${String(VIRTUAL_TIME_MS)}`,
33563
+ run.out === void 0 ? "--dump-dom" : `--screenshot=${run.out}`,
33564
+ run.url
33565
+ ];
33566
+ let stdout = "";
33567
+ let exited = false;
33568
+ let spawnError;
33569
+ const child = spawn(run.chrome, args, {
33570
+ detached: true,
33571
+ stdio: ["ignore", run.out === void 0 ? "pipe" : "ignore", "ignore"]
33572
+ });
33573
+ child.stdout?.on("data", (chunk) => {
33574
+ stdout += chunk.toString();
33575
+ });
33576
+ child.on("exit", () => {
33577
+ exited = true;
33578
+ });
33579
+ child.on("error", (err) => {
33580
+ exited = true;
33581
+ spawnError = `could not start ${run.chrome}: ${err.message}`;
33582
+ });
33583
+ const deadline = Date.now() + run.timeoutMs;
33584
+ let lastSize = -1;
33585
+ let ok = false;
33586
+ try {
33587
+ while (Date.now() < deadline) {
33588
+ if (spawnError !== void 0) break;
33589
+ if (run.out === void 0) {
33590
+ if (/<\/html>\s*$/i.test(stdout) || exited) {
33591
+ ok = stdout.trim() !== "";
33592
+ break;
33593
+ }
33594
+ } else {
33595
+ let size2 = -1;
33596
+ try {
33597
+ size2 = (await stat2(run.out)).size;
33598
+ } catch {
33599
+ size2 = -1;
33600
+ }
33601
+ if (size2 > 0 && size2 === lastSize) {
33602
+ ok = true;
33603
+ break;
33604
+ }
33605
+ lastSize = size2;
33606
+ if (exited && size2 <= 0) break;
33607
+ }
33608
+ await sleep2(exited ? 50 : 150);
33609
+ }
33610
+ } finally {
33611
+ killGroup(child);
33612
+ await rm5(profile, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch(() => void 0);
33613
+ }
33614
+ if (ok) return { ok, stdout, profile };
33615
+ const error = spawnError ?? (Date.now() >= deadline ? `timed out after ${String(Math.round(run.timeoutMs / 1e3))}s` : run.out === void 0 ? "Chrome printed no DOM" : "Chrome exited without writing the screenshot");
33616
+ return { ok, stdout, profile, error };
33617
+ }
33618
+ function pngSize(bytes) {
33619
+ if (bytes.length < 24 || bytes.readUInt32BE(0) !== 2303741511 || bytes.toString("ascii", 12, 16) !== "IHDR") {
33620
+ return null;
33621
+ }
33622
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
33623
+ }
33624
+ function pageName(entry) {
33625
+ return entry.replace(/\.html?$/i, "").replace(/\//g, "_");
33626
+ }
33627
+ function safeRole(role) {
33628
+ return role.replace(/[^A-Za-z0-9._-]/g, "_");
33629
+ }
33630
+ async function runScreenshots(opts) {
33631
+ const now = opts.now ?? Date.now;
33632
+ const started = now();
33633
+ const budgetUntil = started + opts.budgetMs;
33634
+ const shotTimeout = opts.shotTimeoutMs ?? SHOT_TIMEOUT_MS;
33635
+ await mkdir8(opts.outDir, { recursive: true });
33636
+ const answers = fixtureAnswers(opts.cases);
33637
+ const shelf2 = new Shelf(opts.shelf, opts.fetchImpl);
33638
+ const stub = await startStubServer({
33639
+ appId: opts.appId,
33640
+ files: opts.files,
33641
+ manifest: opts.manifest,
33642
+ ...opts.allowedByRole === void 0 ? {} : { allowedByRole: opts.allowedByRole },
33643
+ ...opts.sources === void 0 ? {} : { sources: opts.sources },
33644
+ answers,
33645
+ shelf: shelf2
33646
+ });
33647
+ const plan = [];
33648
+ for (const entry of opts.pages) for (const role of opts.roles) plan.push({ entry, role });
33649
+ const shots = new Array(plan.length);
33650
+ let nextId = 0;
33651
+ const one = async (index2) => {
33652
+ const { entry, role } = plan[index2];
33653
+ const file = `${pageName(entry)}.${safeRole(role)}.png`;
33654
+ const base = {
33655
+ page: entry,
33656
+ role,
33657
+ path: null,
33658
+ viewport: { width: opts.width, height: opts.height },
33659
+ size: null,
33660
+ bytes: 0,
33661
+ full: opts.full,
33662
+ ms: 0,
33663
+ handlersStubbed: [],
33664
+ handlersWithoutFixture: [],
33665
+ handlersRefused: []
33666
+ };
33667
+ if (role !== NO_ROLE && !maySeePage(opts.manifest, role, entry)) {
33668
+ shots[index2] = { ...base, skipped: `${role} may not open this page` };
33669
+ return;
33670
+ }
33671
+ const remaining = budgetUntil - now();
33672
+ if (remaining <= 1e3) {
33673
+ shots[index2] = { ...base, skipped: "the screenshot budget ran out" };
33674
+ return;
33675
+ }
33676
+ const t0 = now();
33677
+ const pageUntil = Math.min(t0 + shotTimeout, budgetUntil);
33678
+ const attemptMs = () => Math.max(500, Math.min(ATTEMPT_MS, Math.ceil(shotTimeout / 2), pageUntil - now()));
33679
+ let height = opts.height;
33680
+ if (opts.full) {
33681
+ const mid = nextId++;
33682
+ stub.register(mid, { role, measure: true });
33683
+ const measured = await runChrome({
33684
+ chrome: opts.chrome,
33685
+ url: stub.urlFor(mid, entry),
33686
+ width: opts.width,
33687
+ height: opts.height,
33688
+ timeoutMs: Math.min(MEASURE_MS, attemptMs())
33689
+ });
33690
+ const found = /id="dr-shot-height"[^>]*content="(\d+)"|content="(\d+)"[^>]*id="dr-shot-height"/.exec(
33691
+ measured.stdout
33692
+ );
33693
+ const tall = Number(found?.[1] ?? found?.[2] ?? NaN);
33694
+ if (Number.isFinite(tall)) height = Math.min(FULL_HEIGHT_CAP, Math.max(opts.height, tall));
33695
+ }
33696
+ const out = join17(opts.outDir, file);
33697
+ let id = -1;
33698
+ let result;
33699
+ for (let attempt = 0; attempt < 2 && now() < pageUntil - 500; attempt++) {
33700
+ id = nextId++;
33701
+ stub.register(id, { role });
33702
+ await rm5(out, { force: true });
33703
+ result = await runChrome({
33704
+ chrome: opts.chrome,
33705
+ url: stub.urlFor(id, entry),
33706
+ width: opts.width,
33707
+ height,
33708
+ timeoutMs: attemptMs(),
33709
+ out
33710
+ });
33711
+ if (result.ok) break;
33712
+ }
33713
+ if (result === void 0) {
33714
+ shots[index2] = { ...base, skipped: "the screenshot budget ran out" };
33715
+ return;
33716
+ }
33717
+ const calls = stub.calls(id);
33718
+ const entryOut = {
33719
+ ...base,
33720
+ viewport: { width: opts.width, height },
33721
+ ms: now() - t0,
33722
+ handlersStubbed: calls.answered,
33723
+ handlersWithoutFixture: calls.noFixture,
33724
+ handlersRefused: calls.refused
33725
+ };
33726
+ if (!result.ok) {
33727
+ shots[index2] = { ...entryOut, error: result.error ?? "no picture" };
33728
+ return;
33729
+ }
33730
+ const bytes = await readFile12(out);
33731
+ shots[index2] = { ...entryOut, path: file, size: pngSize(bytes), bytes: bytes.length };
33732
+ };
33733
+ try {
33734
+ let cursor = 0;
33735
+ const workers = Array.from({ length: Math.max(1, Math.min(opts.concurrency ?? SHOT_CONCURRENCY, plan.length)) }, async () => {
33736
+ while (cursor < plan.length) {
33737
+ const index2 = cursor++;
33738
+ await one(index2);
33739
+ }
33740
+ });
33741
+ await Promise.all(workers);
33742
+ } finally {
33743
+ await stub.close();
33744
+ }
33745
+ const index = {
33746
+ version: 1,
33747
+ app: opts.appId,
33748
+ pagesFrom: opts.pagesFrom,
33749
+ shelf: opts.shelf.kind === "server" ? { kind: "server", url: opts.shelf.baseUrl } : { kind: "local", path: opts.shelf.root },
33750
+ shelfMisses: [...shelf2.misses].sort(),
33751
+ chrome: opts.chrome,
33752
+ roles: opts.roles,
33753
+ fixtures: Object.fromEntries([...answers].map(([name, answer]) => [name, answer.from])),
33754
+ createdAt: new Date(started).toISOString(),
33755
+ ms: now() - started,
33756
+ shots
33757
+ };
33758
+ await writeFile9(join17(opts.outDir, "index.json"), JSON.stringify(index, null, 2) + "\n");
33759
+ return index;
33760
+ }
33761
+ async function readLocalUi(dir) {
33762
+ const root = join17(dir, "ui");
33763
+ const out = {};
33764
+ const walk5 = async (folder) => {
33765
+ let names;
33766
+ try {
33767
+ names = await readdir3(folder, { withFileTypes: true });
33768
+ } catch {
33769
+ return;
33770
+ }
33771
+ for (const one of names) {
33772
+ const full = join17(folder, one.name);
33773
+ if (one.isDirectory()) await walk5(full);
33774
+ else if (one.isFile()) out[relative3(root, full).split(sep6).join("/")] = await readFile12(full);
33775
+ }
33776
+ };
33777
+ await walk5(root);
33778
+ return out;
33779
+ }
33780
+ function looksLikeShelf(root) {
33781
+ return existsSync3(join17(root, "vendor"));
33782
+ }
33783
+ function renderShotTable(index, outDir) {
33784
+ const rows2 = index.shots.map((shot) => [
33785
+ shot.page,
33786
+ shot.role,
33787
+ shot.path === null ? "-" : shot.size === null ? "?" : `${String(shot.size.width)}x${String(shot.size.height)}`,
33788
+ shot.path === null ? "" : `${(shot.ms / 1e3).toFixed(1)}s`,
33789
+ shot.path ?? (shot.skipped !== void 0 ? `skipped: ${shot.skipped}` : `failed: ${shot.error ?? "?"}`)
33790
+ ]);
33791
+ const head = ["page", "role", "size", "time", "file"];
33792
+ const widths = head.map((h, i) => Math.max(h.length, ...rows2.map((row) => (row[i] ?? "").length)));
33793
+ const line2 = (cells) => " " + cells.map((cell, i) => i === cells.length - 1 ? cell : cell.padEnd(widths[i] ?? 0)).join(" ") + "\n";
33794
+ const written = index.shots.filter((shot) => shot.path !== null).length;
33795
+ let out = `
33796
+ screenshots \u2192 ${outDir} (${String(written)} of ${String(index.shots.length)} written in ${(index.ms / 1e3).toFixed(1)}s; pages from the ${index.pagesFrom === "pushed" ? "pushed package" : "local ui/ folder"})
33797
+ `;
33798
+ out += line2(head);
33799
+ for (const row of rows2) out += line2(row);
33800
+ const noFixture = [...new Set(index.shots.flatMap((shot) => shot.handlersWithoutFixture))].sort();
33801
+ if (noFixture.length > 0) {
33802
+ out += ` no tests.json fixture (answered []): ${noFixture.join(", ")}
33803
+ `;
33804
+ }
33805
+ if (index.shelfMisses.length > 0) {
33806
+ out += ` not on the shelf (${index.shelf.kind === "server" ? index.shelf.url : index.shelf.path}): ${index.shelfMisses.slice(0, 5).join(", ")}${index.shelfMisses.length > 5 ? ", \u2026" : ""}
33807
+ `;
33808
+ }
33809
+ out += ` index: ${join17(outDir, "index.json")}
33810
+ `;
33811
+ return out;
33812
+ }
33813
+
33171
33814
  // src/commands/appspace/_roles.ts
33172
33815
  var PREVIEW_ROLE_HEADER = "x-app-space-preview-role";
33173
33816
  function asAllowed(raw) {
@@ -34720,6 +35363,23 @@ function formatFindings(findings) {
34720
35363
  }
34721
35364
 
34722
35365
  // src/commands/appspace/test.ts
35366
+ function positiveInt(raw, fallback, flag) {
35367
+ const value = raw === void 0 ? fallback : Number.parseInt(raw, 10);
35368
+ if (!Number.isFinite(value) || value <= 0 || raw !== void 0 && !/^\d+$/.test(raw.trim())) {
35369
+ throw new Error(`${flag} must be a positive whole number.`);
35370
+ }
35371
+ return value;
35372
+ }
35373
+ function collectRoles(value, previous = []) {
35374
+ return [...previous, ...value.split(",").map((one) => one.trim()).filter((one) => one !== "")];
35375
+ }
35376
+ async function readLocalManifest(dir) {
35377
+ try {
35378
+ return JSON.parse(await readFile13(join18(dir, "manifest.json"), "utf8"));
35379
+ } catch {
35380
+ return null;
35381
+ }
35382
+ }
34723
35383
  var DEFAULT_FIXTURES = "tests.json";
34724
35384
  function orderPages(paths) {
34725
35385
  const html = paths.filter((path3) => /\.html?$/i.test(path3) && isUiEntry(path3));
@@ -34766,7 +35426,8 @@ async function readAppFacts(client, appId) {
34766
35426
  pages: orderPages(files),
34767
35427
  bundle,
34768
35428
  handlerCode,
34769
- handlers
35429
+ handlers,
35430
+ ...pkg.manifest === void 0 ? {} : { manifest: pkg.manifest }
34770
35431
  };
34771
35432
  }
34772
35433
  var UI_BUDGET_MS = 21e4;
@@ -34854,10 +35515,10 @@ function evaluateCase(kase, res) {
34854
35515
  return problems;
34855
35516
  }
34856
35517
  async function loadFixtures(dir, explicit) {
34857
- const path3 = explicit ?? join17(dir, DEFAULT_FIXTURES);
35518
+ const path3 = explicit ?? join18(dir, DEFAULT_FIXTURES);
34858
35519
  let raw;
34859
35520
  try {
34860
- raw = await readFile12(path3, "utf8");
35521
+ raw = await readFile13(path3, "utf8");
34861
35522
  } catch {
34862
35523
  if (explicit !== void 0) throw new Error(`no fixtures file at ${path3}.`);
34863
35524
  return { fixtures: { cases: [], ui: { entry: "index.html" } } };
@@ -34878,7 +35539,7 @@ async function runHandlerCases(cases, dir, appId, client, roleOf = () => void 0)
34878
35539
  if (!code.has(kase.handler)) {
34879
35540
  code.set(
34880
35541
  kase.handler,
34881
- await readFile12(join17(dir, "handlers", `${kase.handler}.py`), "utf8").catch(() => null)
35542
+ await readFile13(join18(dir, "handlers", `${kase.handler}.py`), "utf8").catch(() => null)
34882
35543
  );
34883
35544
  }
34884
35545
  const source = code.get(kase.handler);
@@ -35133,7 +35794,7 @@ function contractUid(contract, page, component) {
35133
35794
  }
35134
35795
  async function isDashboardFolder2(dir) {
35135
35796
  try {
35136
- const doc = JSON.parse(await readFile12(join17(dir, "contract.json"), "utf8"));
35797
+ const doc = JSON.parse(await readFile13(join18(dir, "contract.json"), "utf8"));
35137
35798
  return doc.app?.kind === "dashboard";
35138
35799
  } catch {
35139
35800
  return false;
@@ -35334,7 +35995,7 @@ function mergeDomComponents(contract, components, page) {
35334
35995
  async function writeMergedContract(dir, page, components) {
35335
35996
  let contract;
35336
35997
  try {
35337
- contract = JSON.parse(await readFile12(contractPath(dir), "utf8"));
35998
+ contract = JSON.parse(await readFile13(contractPath(dir), "utf8"));
35338
35999
  } catch {
35339
36000
  contract = await extractContract(dir);
35340
36001
  }
@@ -35344,7 +36005,7 @@ async function writeMergedContract(dir, page, components) {
35344
36005
  async function uiIsStale(dir, entry, stored) {
35345
36006
  if (stored === void 0) return false;
35346
36007
  try {
35347
- const local = await readFile12(join17(dir, "ui", entry));
36008
+ const local = await readFile13(join18(dir, "ui", entry));
35348
36009
  return !local.equals(Buffer.from(stored, "utf8"));
35349
36010
  } catch {
35350
36011
  return false;
@@ -35432,7 +36093,7 @@ function untouchedHandlers(declared, cases, interactions, runs = []) {
35432
36093
  async function readSeed(dir) {
35433
36094
  let parsed;
35434
36095
  try {
35435
- parsed = JSON.parse(await readFile12(join17(dir, "seed.json"), "utf8"));
36096
+ parsed = JSON.parse(await readFile13(join18(dir, "seed.json"), "utf8"));
35436
36097
  } catch {
35437
36098
  return null;
35438
36099
  }
@@ -35448,7 +36109,7 @@ async function readSeed(dir) {
35448
36109
  }
35449
36110
  async function handlerOwnership(dir, pushed) {
35450
36111
  try {
35451
- const local = JSON.parse(await readFile12(join17(dir, "contract.json"), "utf8"));
36112
+ const local = JSON.parse(await readFile13(join18(dir, "contract.json"), "utf8"));
35452
36113
  const handlers2 = local.data?.app?.handlers;
35453
36114
  if (typeof handlers2 === "object" && handlers2 !== null) return handlers2;
35454
36115
  } catch {
@@ -35564,6 +36225,20 @@ function registerTestCommand(parent) {
35564
36225
  ).option("--keep-test-rows", "Keep the rows this run wrote, even on a draft.").option(
35565
36226
  "--contract",
35566
36227
  "Fold the rendered page's data-dr-* components into .appspace/contract.json, so the record says what the app DREW as well as what it declares."
36228
+ ).option(
36229
+ "--screenshot",
36230
+ "After the checks, draw every pushed page once per declared role in headless Google Chrome and write <out>/<page>.<role>.png plus index.json. The pages load from a local stub (127.0.0.1) that answers /access as each role and every handler from its tests.json fixture ([] when none): no row is written. Needs Chrome on PATH or CHROME_PATH; nothing is downloaded."
36231
+ ).option("--out <dir>", "Where --screenshot writes (default: <dir>/.screenshots).").option(
36232
+ "--role <name>",
36233
+ "With --screenshot: draw only this declared role (repeatable, or comma-separated). Default: every declared role.",
36234
+ collectRoles
36235
+ ).option("--width <px>", "With --screenshot: the window width.", "1400").option("--height <px>", "With --screenshot: the window height.", "900").option("--full", "With --screenshot: grow the window to the page\u2019s full height (one extra load per picture).").option(
36236
+ "--shelf <path>",
36237
+ "With --screenshot: serve vendor/ and ui-kit/ from this dr-app-space checkout instead of the server the app is pushed to (for offline use)."
36238
+ ).option(
36239
+ "--screenshot-budget <ms>",
36240
+ `With --screenshot: the most the whole screenshot phase may take; each picture is capped at 60s.`,
36241
+ String(SHOT_BUDGET_MS)
35567
36242
  )
35568
36243
  )
35569
36244
  ).action(async (dirArg, opts) => {
@@ -35580,10 +36255,30 @@ function registerTestCommand(parent) {
35580
36255
  }
35581
36256
  const mode = parseInteractMode(opts.interact);
35582
36257
  const as = opts.as === void 0 ? void 0 : opts.as.trim();
36258
+ let chrome = null;
36259
+ let shotSize = { width: 0, height: 0, budget: 0 };
36260
+ if (opts.screenshot === true) {
36261
+ chrome = findChrome();
36262
+ if (chrome === null) {
36263
+ process.stderr.write(`error: ${CHROME_MISSING}
36264
+ `);
36265
+ process.exitCode = 2;
36266
+ return;
36267
+ }
36268
+ shotSize = {
36269
+ width: positiveInt(opts.width, 1400, "--width"),
36270
+ height: positiveInt(opts.height, 900, "--height"),
36271
+ budget: positiveInt(opts.screenshotBudget, SHOT_BUDGET_MS, "--screenshot-budget")
36272
+ };
36273
+ if (opts.shelf !== void 0 && !looksLikeShelf(opts.shelf)) {
36274
+ throw new Error(`--shelf ${opts.shelf}: no vendor/ folder there; point it at a dr-app-space checkout.`);
36275
+ }
36276
+ }
35583
36277
  const client = await connect(opts);
35584
36278
  try {
35585
36279
  const view = await readRoleView(client, appId);
35586
36280
  if (as !== void 0) requireDeclaredRole(as, view);
36281
+ if (chrome !== null && view.roles.length > 0) shotRoles({ roles: view.roles.map((name) => ({ name })) }, opts.role ?? []);
35587
36282
  const roleOf = (handler) => as ?? roleForHandler(handler, view);
35588
36283
  const clock = await readServerClock(client, appId);
35589
36284
  const outcomes = opts.skipHandlers === true ? [] : await runHandlerCases(fixtures.cases, dir, appId, client, roleOf);
@@ -35718,6 +36413,33 @@ function registerTestCommand(parent) {
35718
36413
  skipped.push(...findingsNotChecked(facts !== void 0, pushed));
35719
36414
  skipped.push(...leversNotChecked(opts.skipUi === true && !await isDashboardFolder2(dir), runs, pushed));
35720
36415
  const parts = facts === void 0 ? null : partsSummary(pushed, facts.bundle, facts.handlerCode);
36416
+ let shots;
36417
+ if (chrome !== null) {
36418
+ const pushedPages = store.problem === void 0 && store.pages.length > 0;
36419
+ const files = pushedPages ? store.bundle : await readLocalUi(dir);
36420
+ const pages = pushedPages ? store.pages : orderPages(Object.keys(files));
36421
+ const manifest = pushedPages && typeof store.manifest === "object" && store.manifest !== null ? store.manifest : await readLocalManifest(dir);
36422
+ const outDir = resolve16(opts.out ?? join18(dir, ".screenshots"));
36423
+ const index = await runScreenshots({
36424
+ appId,
36425
+ pages: choosePages(opts.pages, pages, fixtures.ui.entry).load,
36426
+ files,
36427
+ pagesFrom: pushedPages ? "pushed" : "local",
36428
+ manifest,
36429
+ roles: shotRoles(manifest, opts.role ?? []),
36430
+ allowedByRole: view.allowed,
36431
+ sources: Object.keys(pushed?.data?.sources ?? {}),
36432
+ cases: fixtures.cases,
36433
+ shelf: opts.shelf === void 0 ? { kind: "server", baseUrl: client.baseUrl } : { kind: "local", root: resolve16(opts.shelf) },
36434
+ chrome,
36435
+ outDir,
36436
+ width: shotSize.width,
36437
+ height: shotSize.height,
36438
+ full: opts.full === true,
36439
+ budgetMs: shotSize.budget
36440
+ });
36441
+ shots = { index, outDir };
36442
+ }
35721
36443
  const handlerFailures = outcomes.filter((o) => !o.ok).length;
35722
36444
  const smokeFailures = runs.reduce(
35723
36445
  (total, run) => total + failedCalls(run.report).length + run.report.consoleErrors.length + run.report.pageErrors.length + (run.report.interactions ?? []).filter((one) => one.problems.length > 0).length,
@@ -35773,6 +36495,8 @@ function registerTestCommand(parent) {
35773
36495
  parts,
35774
36496
  // The test rows the run found, removed, and left (`_testRows.ts`).
35775
36497
  rows: rows2,
36498
+ // `--screenshot`: the same document as <out>/index.json, or null.
36499
+ screenshots: shots === void 0 ? null : { ...shots.index, outDir: shots.outDir },
35776
36500
  strict: opts.strict === true,
35777
36501
  passed: outcomes.length - handlerFailures,
35778
36502
  failed,
@@ -35800,6 +36524,7 @@ ${String(failed)} failure(s).
35800
36524
  out += rowsLine(rows2);
35801
36525
  out += formatSkipped(skipped);
35802
36526
  if (facts !== void 0) out += formatPartsSummary(parts);
36527
+ if (shots !== void 0) out += renderShotTable(shots.index, shots.outDir);
35803
36528
  out += formatFindings(findings);
35804
36529
  process.stdout.write(out);
35805
36530
  if (exitCodeFor(failed, findings, opts.strict === true) !== 0) process.exitCode = 1;
@@ -35810,12 +36535,12 @@ ${String(failed)} failure(s).
35810
36535
  }
35811
36536
 
35812
36537
  // src/commands/appspace/try.ts
35813
- import { readFile as readFile13 } from "fs/promises";
35814
- import { join as join18 } from "path";
36538
+ import { readFile as readFile14 } from "fs/promises";
36539
+ import { join as join19 } from "path";
35815
36540
  function collect2(value, previous = []) {
35816
36541
  return [...previous, value];
35817
36542
  }
35818
- async function readPayloadOption(raw, read = (path3) => readFile13(path3, "utf8")) {
36543
+ async function readPayloadOption(raw, read = (path3) => readFile14(path3, "utf8")) {
35819
36544
  if (raw.startsWith("@")) {
35820
36545
  const path3 = raw.slice(1);
35821
36546
  if (path3 === "") throw new Error("--payload @ names no file; write --payload @./payload.json.");
@@ -35890,8 +36615,8 @@ function registerTryCommand(parent) {
35890
36615
  ).action(async (handler, opts) => {
35891
36616
  requirePlainName("handler", handler, "read");
35892
36617
  const appId = await readManifestId(opts.dir);
35893
- const code = await readFile13(
35894
- join18(opts.dir, "handlers", `${handler}.py`),
36618
+ const code = await readFile14(
36619
+ join19(opts.dir, "handlers", `${handler}.py`),
35895
36620
  "utf8"
35896
36621
  ).catch(() => {
35897
36622
  throw new Error(
@@ -36012,8 +36737,8 @@ function registerPublishCommands(parent) {
36012
36737
  }
36013
36738
 
36014
36739
  // src/commands/appspace/scaffold.ts
36015
- import { mkdir as mkdir8, readdir as readdir3, stat as stat2, writeFile as writeFile9 } from "fs/promises";
36016
- import { dirname as dirname9, join as join19 } from "path";
36740
+ import { mkdir as mkdir9, readdir as readdir4, stat as stat3, writeFile as writeFile10 } from "fs/promises";
36741
+ import { dirname as dirname9, join as join20 } from "path";
36017
36742
 
36018
36743
  // src/commands/appspace/starters.ts
36019
36744
  var STARTER_KINDS = ["dashboard", "app"];
@@ -36350,7 +37075,7 @@ async function checkTarget(dir, force) {
36350
37075
  if (force) return;
36351
37076
  let entries;
36352
37077
  try {
36353
- entries = await readdir3(dir);
37078
+ entries = await readdir4(dir);
36354
37079
  } catch (err) {
36355
37080
  if (err.code === "ENOENT") return;
36356
37081
  throw new Error(
@@ -36370,7 +37095,7 @@ async function existingFiles(dir, relPaths) {
36370
37095
  const present = [];
36371
37096
  for (const rel of relPaths) {
36372
37097
  try {
36373
- await stat2(join19(dir, rel));
37098
+ await stat3(join20(dir, rel));
36374
37099
  present.push(rel);
36375
37100
  } catch {
36376
37101
  }
@@ -36409,13 +37134,13 @@ function registerScaffoldCommand(parent) {
36409
37134
  const table = ownsData ? (opts.table ?? DEFAULT_TABLE).trim() : void 0;
36410
37135
  const record2 = ownsData ? ((opts.record ?? "").trim() || singularize(table ?? DEFAULT_TABLE)).trim() : void 0;
36411
37136
  checkNames({ id, table, record: record2 });
36412
- const dir = (dirArg ?? "").trim() || join19(".", id);
37137
+ const dir = (dirArg ?? "").trim() || join20(".", id);
36413
37138
  await checkTarget(dir, opts.force === true);
36414
37139
  const contract = starterContract(kind, { id, name, table, record: record2 });
36415
37140
  const overwritten = opts.force === true ? await existingFiles(dir, [CONTRACT_FILE]) : [];
36416
- const target = join19(dir, CONTRACT_FILE);
36417
- await mkdir8(dirname9(target), { recursive: true });
36418
- await writeFile9(target, `${JSON.stringify(contract, null, 2)}
37141
+ const target = join20(dir, CONTRACT_FILE);
37142
+ await mkdir9(dirname9(target), { recursive: true });
37143
+ await writeFile10(target, `${JSON.stringify(contract, null, 2)}
36419
37144
  `);
36420
37145
  if (opts.json === true) {
36421
37146
  printJson({ id, name, dir, kind, table, record: record2, files: [CONTRACT_FILE], overwritten });
@@ -36528,40 +37253,40 @@ function registerAppSpaceCommand(root) {
36528
37253
  }
36529
37254
 
36530
37255
  // src/commands/update.ts
36531
- import { spawn } from "child_process";
37256
+ import { spawn as spawn2 } from "child_process";
36532
37257
  var REGISTRY = "https://datarails.jfrog.io/artifactory/api/npm/dr-cli-virtual";
36533
37258
  var PACKAGE = "@datarails-ai/dr-appspace-cli";
36534
37259
  function spawnInherit(command, args) {
36535
- return new Promise((resolve15) => {
36536
- const child = spawn(command, args, {
37260
+ return new Promise((resolve17) => {
37261
+ const child = spawn2(command, args, {
36537
37262
  stdio: "inherit",
36538
37263
  // Windows resolves `npm` as `npm.cmd`; `shell: true` lets cmd.exe do
36539
37264
  // the resolution without us having to special-case the suffix.
36540
37265
  shell: true
36541
37266
  });
36542
37267
  child.on("close", (code) => {
36543
- resolve15(code ?? 1);
37268
+ resolve17(code ?? 1);
36544
37269
  });
36545
37270
  child.on("error", () => {
36546
- resolve15(1);
37271
+ resolve17(1);
36547
37272
  });
36548
37273
  });
36549
37274
  }
36550
37275
  function captureVersion() {
36551
- return new Promise((resolve15) => {
36552
- const child = spawn("dr-appspace", ["--version"], { shell: true });
37276
+ return new Promise((resolve17) => {
37277
+ const child = spawn2("dr-appspace", ["--version"], { shell: true });
36553
37278
  let out = "";
36554
37279
  child.stdout.on("data", (chunk) => {
36555
37280
  out += chunk.toString();
36556
37281
  });
36557
37282
  child.on("close", (code) => {
36558
37283
  if (code !== 0) {
36559
- resolve15(null);
37284
+ resolve17(null);
36560
37285
  return;
36561
37286
  }
36562
- resolve15(out.trim());
37287
+ resolve17(out.trim());
36563
37288
  });
36564
- child.on("error", () => resolve15(null));
37289
+ child.on("error", () => resolve17(null));
36565
37290
  });
36566
37291
  }
36567
37292
  async function runUpdate() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datarails-ai/dr-appspace-cli",
3
- "version": "0.12.31",
3
+ "version": "0.12.33",
4
4
  "description": "Datarails App Space CLI — build, try, push and publish App Space apps",
5
5
  "type": "module",
6
6
  "engines": {