@glubean/cli 0.8.3 โ†’ 0.9.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.
@@ -1,14 +1,15 @@
1
1
  import { bootstrap, evaluateThresholds, MetricCollector, ProjectRunner, buildRunContext, } from "@glubean/runner";
2
+ import { buildOnlySelectorsFromFlags, deriveRerunSelectors } from "../lib/only-selectors.js";
2
3
  import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { stat, readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises";
5
6
  import { glob } from "node:fs/promises";
6
7
  import { CONFIG_DEFAULTS, mergeRunOptions, toSharedRunConfig } from "../lib/config.js";
7
8
  import { loadProjectEnv } from "@glubean/runner";
8
- import { resolveEnvFileName } from "../lib/active_env.js";
9
+ import { resolveEnvFileName, SensitiveActiveEnvError } from "../lib/active_env.js";
9
10
  import { shouldSkipTest } from "../lib/skip.js";
10
11
  import { extractContractCases, extractFromSource } from "@glubean/scanner/static";
11
- import { buildSuffixes, classifyByStem, extractContractFromFile, findTemplateMatch, GLUBEAN_KINDS, loadProjectOverlays, matchesTemplateFilter, } from "@glubean/scanner";
12
+ import { buildSuffixes, classifyByStem, extractContractFromFile, findTemplateMatch, findTemplateMatches, GLUBEAN_KINDS, loadProjectOverlays, matchesTemplateFilter, } from "@glubean/scanner";
12
13
  import { applyEnvTemplating } from "@glubean/runner";
13
14
  // ANSI color codes for pretty output
14
15
  const colors = {
@@ -519,8 +520,8 @@ export async function runCommand(target, options = {}) {
519
520
  const interactive = capabilityProfile.browser;
520
521
  const traceCollector = [];
521
522
  console.log(`\n${colors.bold}${colors.blue}๐Ÿงช Glubean Test Runner${colors.reset}\n`);
522
- const testFiles = await resolveTestFiles(target);
523
- const isMultiFile = testFiles.length > 1;
523
+ let testFiles = await resolveTestFiles(target);
524
+ let isMultiFile = testFiles.length > 1;
524
525
  // Single string view of target for serialization / display paths
525
526
  // (result.json, junit, traces). Multi-suite passes an array; join with
526
527
  // ", " so downstream consumers still see a printable target field.
@@ -544,6 +545,59 @@ export async function runCommand(target, options = {}) {
544
545
  }
545
546
  const startDir = testFiles[0].substring(0, testFiles[0].lastIndexOf("/"));
546
547
  const { rootDir } = await findProjectConfig(startDir);
548
+ // โ”€โ”€ B2 M3 โ€” `{id, rowIndex}` "only" selectors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
549
+ // Validate the --only-id / --row / --rerun-failed combo up front (single
550
+ // gate), then resolve the active selector set. `--rerun-failed` reads the
551
+ // previous run and narrows discovery to the files that failed; `--only-id` /
552
+ // `--row` narrow `testsToRun` below by template-matching concrete ids against
553
+ // static `.each` template ids. Either way the precise per-row filter runs in
554
+ // the harness subprocess via the GLUBEAN_RUNNER_ONLY_SELECTORS env channel.
555
+ let onlySelectors = [];
556
+ const selectorFlags = buildOnlySelectorsFromFlags({
557
+ onlyId: options.onlyId,
558
+ row: options.row,
559
+ rerunFailed: options.rerunFailed,
560
+ });
561
+ if (!selectorFlags.ok) {
562
+ console.error(`\n${colors.red}โŒ ${selectorFlags.error}${colors.reset}\n`);
563
+ process.exit(1);
564
+ }
565
+ if (options.rerunFailed) {
566
+ const lastRunPath = resolve(rootDir, ".glubean", "last-run.result.json");
567
+ let lastRun;
568
+ try {
569
+ lastRun = JSON.parse(await readFile(lastRunPath, "utf-8"));
570
+ }
571
+ catch {
572
+ console.error(`\n${colors.red}โŒ No previous run found. Run \`glubean run\` first.${colors.reset}\n` +
573
+ `${colors.dim}--rerun-failed reads ${lastRunPath}.${colors.reset}\n`);
574
+ process.exit(1);
575
+ }
576
+ const { selectors, files } = deriveRerunSelectors({ tests: lastRun.tests ?? [] });
577
+ if (selectors.length === 0) {
578
+ console.log(`\n${colors.green}โœ“ Last run had no failures โ€” nothing to rerun.${colors.reset}\n`);
579
+ process.exit(0);
580
+ }
581
+ // Narrow discovery to the files that contained a failure. `files` were
582
+ // written relative to rootDir (resultPayload.tests.filePath) โ€” rootDir is the
583
+ // stable basis (the dir holding .glubean/), so rerun resolves correctly even
584
+ // when invoked from a different cwd than the original run. Resolve both sides
585
+ // to absolute for an exact match against the discovered testFiles.
586
+ const failedFilesAbs = new Set(files.map((f) => resolve(rootDir, f)));
587
+ testFiles = testFiles.filter((f) => failedFilesAbs.has(resolve(f)));
588
+ isMultiFile = testFiles.length > 1;
589
+ if (testFiles.length === 0) {
590
+ console.error(`\n${colors.red}โŒ --rerun-failed: none of the ${failedFilesAbs.size} failed file(s) ` +
591
+ `from the last run are in the current target.${colors.reset}\n`);
592
+ process.exit(1);
593
+ }
594
+ onlySelectors = selectors;
595
+ console.log(`${colors.dim}--rerun-failed: ${selectors.length} failed test(s) across ` +
596
+ `${testFiles.length} file(s)${colors.reset}\n`);
597
+ }
598
+ else {
599
+ onlySelectors = selectorFlags.selectors;
600
+ }
547
601
  // Config consolidation (docs/06 P2): the legacy package.json `glubean`
548
602
  // flat-shape is no longer read. Profile runs get run/redaction/thresholds
549
603
  // from the resolved plan (threaded via `options`); non-profile target runs
@@ -573,10 +627,27 @@ export async function runCommand(target, options = {}) {
573
627
  console.log(`${colors.dim}Log file: ${logPath}${colors.reset}`);
574
628
  }
575
629
  // Resolve env file: --env-file flag > .glubean/active-env > config default > .env
630
+ // GLU-88: resolveEnvFileName throws SensitiveActiveEnvError instead of
631
+ // silently returning a prod-like active-env file โ€” surface it as a clear,
632
+ // actionable CLI error rather than let it propagate as an unhandled
633
+ // rejection.
576
634
  const userSpecifiedEnvFile = !!options.envFile;
577
- const envFileName = userSpecifiedEnvFile
578
- ? effectiveRun.envFile
579
- : await resolveEnvFileName(rootDir);
635
+ let envFileName;
636
+ if (userSpecifiedEnvFile) {
637
+ envFileName = effectiveRun.envFile;
638
+ }
639
+ else {
640
+ try {
641
+ envFileName = await resolveEnvFileName(rootDir);
642
+ }
643
+ catch (err) {
644
+ if (err instanceof SensitiveActiveEnvError) {
645
+ console.error(`\n${colors.red}Error: ${err.message}${colors.reset}\n`);
646
+ process.exit(1);
647
+ }
648
+ throw err;
649
+ }
650
+ }
580
651
  const envPath = resolve(rootDir, envFileName);
581
652
  if (userSpecifiedEnvFile) {
582
653
  try {
@@ -797,7 +868,7 @@ export async function runCommand(target, options = {}) {
797
868
  }
798
869
  const hasTags = options.tags && options.tags.length > 0;
799
870
  const hasExcludeTags = options.excludeTags && options.excludeTags.length > 0;
800
- const testsToRun = allFileTests.filter((ft) => {
871
+ let testsToRun = allFileTests.filter((ft) => {
801
872
  const tc = ft.test;
802
873
  if (tc.meta.skip)
803
874
  return false;
@@ -811,6 +882,45 @@ export async function runCommand(target, options = {}) {
811
882
  return false;
812
883
  return true;
813
884
  });
885
+ // B2 M3 โ€” narrow testsToRun to the selected ids. Selector ids are CONCRETE
886
+ // (e.g. `user-0`); a `.each` export appears in discovery under its TEMPLATE id
887
+ // (e.g. `user-$index`). Template-match each selector id against the discovered
888
+ // template ids so concrete selectors reach the right export โ€” the harness then
889
+ // applies the precise per-row filter at runtime.
890
+ //
891
+ // This runs for `--rerun-failed` too (GLU-67 follow-up): `--rerun-failed`
892
+ // narrows DISCOVERY to the failed FILES above, but a failed file can also hold
893
+ // PASSED tests. Without narrowing testsToRun here as well, those passed tests
894
+ // reach the capability-skip pass below and emit spurious `โŠ˜ skipped` rows that
895
+ // aren't part of the rerun set (the harness `matchOnly` still filters them at
896
+ // runtime, but only after the CLI has already printed the noise). Narrowing here
897
+ // makes the rerun path's ordering match the `--only-id` path: filter to the
898
+ // selected ids FIRST, then capability-skip only what remains.
899
+ //
900
+ // keep-all-matches (GLU-67 follow-up): `findTemplateMatches` (plural) keeps
901
+ // EVERY export a selector id matches โ€” a bare id shared by two files, or two
902
+ // overlapping `.each` templates, all run โ€” instead of `findTemplateMatch`'s
903
+ // silent first-match-wins drop.
904
+ if (onlySelectors.length > 0) {
905
+ const selectorIds = Array.from(new Set(onlySelectors.map((s) => (typeof s === "string" ? s : s.id))));
906
+ const indexed = testsToRun.map((ft) => ({ id: ft.test.meta.id, ft }));
907
+ const kept = new Set();
908
+ for (const selId of selectorIds) {
909
+ for (const match of findTemplateMatches(indexed, selId))
910
+ kept.add(match.ft);
911
+ }
912
+ testsToRun = testsToRun.filter((ft) => kept.has(ft));
913
+ if (testsToRun.length === 0) {
914
+ // In rerun mode the ids come from the last run's failed set, not a
915
+ // `--only-id` flag โ€” report accordingly (e.g. a failed test was renamed
916
+ // or removed since the recorded run).
917
+ console.error(options.rerunFailed
918
+ ? `\n${colors.red}โŒ --rerun-failed: none of the last run's failed test ids ` +
919
+ `(${selectorIds.join(", ")}) still exist in the target files.${colors.reset}\n`
920
+ : `\n${colors.red}โŒ No tests match --only-id ${selectorIds.join(", ")}${colors.reset}\n`);
921
+ process.exit(1);
922
+ }
923
+ }
814
924
  if (testsToRun.length === 0) {
815
925
  if (options.filter || hasTags) {
816
926
  const parts = [];
@@ -952,6 +1062,15 @@ export async function runCommand(target, options = {}) {
952
1062
  delete process.env["GLUBEAN_RUNNER_BOOTSTRAP_INPUT_MAP"];
953
1063
  delete process.env["GLUBEAN_RUNNER_FORCE_STANDALONE_IDS"];
954
1064
  }
1065
+ // B2 M3 โ€” hand the resolved selector set to the harness subprocess (it applies
1066
+ // the precise per-row filter at runtime). Clear it otherwise so a stale value
1067
+ // never leaks across in-process invocations (parity with the input maps above).
1068
+ if (onlySelectors.length > 0) {
1069
+ process.env["GLUBEAN_RUNNER_ONLY_SELECTORS"] = JSON.stringify(onlySelectors);
1070
+ }
1071
+ else {
1072
+ delete process.env["GLUBEAN_RUNNER_ONLY_SELECTORS"];
1073
+ }
955
1074
  if (options.pick) {
956
1075
  process.env.GLUBEAN_PICK = options.pick;
957
1076
  console.log(`${colors.dim} pick: ${options.pick}${colors.reset}`);
@@ -1024,6 +1143,8 @@ export async function runCommand(target, options = {}) {
1024
1143
  let currentTestItems;
1025
1144
  let testId = "";
1026
1145
  let testName = "";
1146
+ let testRowIndex = undefined;
1147
+ let testEach = undefined;
1027
1148
  let testItem = null;
1028
1149
  let startTime = Date.now();
1029
1150
  let testEvents = [];
@@ -1089,6 +1210,8 @@ export async function runCommand(target, options = {}) {
1089
1210
  success: skippedClean ? true : finalSuccess,
1090
1211
  durationMs: duration,
1091
1212
  groupId: testItem?.meta.groupId,
1213
+ rowIndex: testRowIndex,
1214
+ each: testEach,
1092
1215
  });
1093
1216
  addLogEntry("result", skippedClean ? "SKIPPED" : finalSuccess ? "PASSED" : "FAILED", {
1094
1217
  duration,
@@ -1406,6 +1529,14 @@ export async function runCommand(target, options = {}) {
1406
1529
  (currentTestItems ? findFileTestByRuntimeId(currentTestItems, event.id) : undefined);
1407
1530
  testId = event.id;
1408
1531
  testName = entry?.test.meta.name || event.name || event.id;
1532
+ // rowIndex (B2 M3): the runtime start event is authoritative for the
1533
+ // per-row `.each` index (static discovery only sees the template id,
1534
+ // so it carries no rowIndex). undefined for non-each tests.
1535
+ testRowIndex = event.rowIndex;
1536
+ // each (B3 T3): same reasoning โ€” the runtime start event is
1537
+ // authoritative for row-identity provenance (idTemplate/rowKey/
1538
+ // stable). undefined for non-each tests.
1539
+ testEach = event.each;
1409
1540
  testItem = entry?.test || null;
1410
1541
  startTime = Date.now();
1411
1542
  testEvents = [];
@@ -1871,6 +2002,18 @@ export async function runCommand(target, options = {}) {
1871
2002
  success: r.success,
1872
2003
  durationMs: r.durationMs,
1873
2004
  events: r.events,
2005
+ // B2 M3 โ€” persist rowIndex + filePath so `--rerun-failed` can reconstruct
2006
+ // the failed `{id, rowIndex}` selector set and narrow to the failed files.
2007
+ // filePath is relative to rootDir (the stable .glubean/ basis), NOT cwd, so
2008
+ // rerun resolves correctly when run from a different working directory.
2009
+ ...(r.rowIndex !== undefined && { rowIndex: r.rowIndex }),
2010
+ // B3 T3 (`run-evidence-identity-model.md` ยง7/ยง14) โ€” persist row-identity
2011
+ // provenance (idTemplate/rowKey/stable) on each test entry, so the
2012
+ // uploaded run blob (`result.tests[].each`) self-describes row identity
2013
+ // without depending on a projection join. Undefined for non-each tests
2014
+ // (backward compatible: old runs / old CLI builds simply omit the field).
2015
+ ...(r.each !== undefined && { each: r.each }),
2016
+ filePath: relative(rootDir, r.filePath),
1874
2017
  })),
1875
2018
  ...(thresholdSummary && { thresholds: thresholdSummary }),
1876
2019
  ...(options.meta && Object.keys(options.meta).length > 0 && { customMetadata: options.meta }),
@@ -1924,29 +2067,30 @@ export async function runCommand(target, options = {}) {
1924
2067
  }
1925
2068
  }
1926
2069
  // โ”€โ”€ Screenshot paths โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1927
- {
1928
- const screenshotPaths = [];
1929
- for (const run of collectedRuns) {
1930
- for (const event of run.events) {
1931
- if (event.type !== "event")
1932
- continue;
1933
- const ev = event.data;
1934
- if (ev.type === "browser:screenshot" && typeof ev.data?.path === "string") {
1935
- screenshotPaths.push(resolve(rootDir, ev.data.path));
1936
- }
2070
+ // This run's exact screenshot files, pulled from the `browser:screenshot`
2071
+ // event stream. Doubles as the upload whitelist below so `--upload` attaches
2072
+ // only THIS run's screenshots, not every file in the shared dir (ART1).
2073
+ const screenshotPaths = [];
2074
+ for (const run of collectedRuns) {
2075
+ for (const event of run.events) {
2076
+ if (event.type !== "event")
2077
+ continue;
2078
+ const ev = event.data;
2079
+ if (ev.type === "browser:screenshot" && typeof ev.data?.path === "string") {
2080
+ screenshotPaths.push(resolve(rootDir, ev.data.path));
1937
2081
  }
1938
2082
  }
1939
- if (screenshotPaths.length > 0) {
1940
- for (const p of screenshotPaths) {
1941
- console.log(`${colors.dim}Screenshot: ${colors.reset}${p}`);
1942
- }
1943
- console.log();
2083
+ }
2084
+ if (screenshotPaths.length > 0) {
2085
+ for (const p of screenshotPaths) {
2086
+ console.log(`${colors.dim}Screenshot: ${colors.reset}${p}`);
1944
2087
  }
2088
+ console.log();
1945
2089
  }
1946
2090
  // โ”€โ”€ Cloud upload โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1947
2091
  if (options.upload) {
1948
2092
  const { resolveToken, resolveProjectId, resolveApiUrl } = await import("../lib/auth.js");
1949
- const { uploadToCloud } = await import("../lib/upload.js");
2093
+ const { uploadToCloud, removeUploadedScreenshots } = await import("../lib/upload.js");
1950
2094
  const authOpts = {
1951
2095
  token: options.token,
1952
2096
  project: options.project,
@@ -2096,7 +2240,27 @@ export async function runCommand(target, options = {}) {
2096
2240
  targetId,
2097
2241
  envFile: effectiveRun.envFile,
2098
2242
  rootDir,
2243
+ // Upload only THIS run's screenshots (whitelist), not the whole
2244
+ // shared `.glubean/screenshots` dir which accumulates prior runs.
2245
+ screenshotPaths,
2099
2246
  });
2247
+ // ART1-B โ€” the shared screenshots dir only ever grows, so once the
2248
+ // Cloud confirmed it received this run's screenshots, delete the local
2249
+ // copies. Deletes ONLY uploadedFiles โˆฉ screenshotPaths (both server-
2250
+ // confirmed and provably this run's), realpath-contained to
2251
+ // `.glubean/screenshots`, and only while the on-disk file still has
2252
+ // its upload-time stat identity (re-checked just before each unlink)
2253
+ // โ€” a failed/partial upload keeps its files, and a concurrently
2254
+ // recreated path is kept.
2255
+ if (!options.keepLocal &&
2256
+ uploadReceipt.artifactUpload.status === "uploaded" &&
2257
+ uploadReceipt.artifactUpload.uploadedFiles?.length &&
2258
+ screenshotPaths.length > 0) {
2259
+ const { removed } = await removeUploadedScreenshots(rootDir, screenshotPaths, uploadReceipt.artifactUpload.uploadedFiles);
2260
+ if (removed > 0) {
2261
+ console.log(`${colors.dim}Cleaned up ${removed} uploaded screenshot(s) from .glubean/screenshots (use --keep-local to keep them)${colors.reset}`);
2262
+ }
2263
+ }
2100
2264
  if (options.uploadReceiptJson) {
2101
2265
  const receiptPath = resolveOutputPath(options.uploadReceiptJson, process.cwd());
2102
2266
  await mkdir(dirname(receiptPath), { recursive: true });