@glubean/cli 0.8.2 โ†’ 0.8.4

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,4 +1,5 @@
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";
@@ -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
@@ -797,7 +851,7 @@ export async function runCommand(target, options = {}) {
797
851
  }
798
852
  const hasTags = options.tags && options.tags.length > 0;
799
853
  const hasExcludeTags = options.excludeTags && options.excludeTags.length > 0;
800
- const testsToRun = allFileTests.filter((ft) => {
854
+ let testsToRun = allFileTests.filter((ft) => {
801
855
  const tc = ft.test;
802
856
  if (tc.meta.skip)
803
857
  return false;
@@ -811,6 +865,26 @@ export async function runCommand(target, options = {}) {
811
865
  return false;
812
866
  return true;
813
867
  });
868
+ // B2 M3 โ€” narrow testsToRun for --only-id / --row. Selector ids are CONCRETE
869
+ // (e.g. `user-0`); a `.each` export appears in discovery under its TEMPLATE id
870
+ // (e.g. `user-$index`). Template-match each selector id against the discovered
871
+ // template ids so concrete selectors reach the right export โ€” the harness then
872
+ // applies the precise per-row filter at runtime. (Rerun narrows by file above.)
873
+ if (onlySelectors.length > 0 && !options.rerunFailed) {
874
+ const selectorIds = Array.from(new Set(onlySelectors.map((s) => (typeof s === "string" ? s : s.id))));
875
+ const indexed = testsToRun.map((ft) => ({ id: ft.test.meta.id, ft }));
876
+ const kept = new Set();
877
+ for (const selId of selectorIds) {
878
+ const match = findTemplateMatch(indexed, selId);
879
+ if (match)
880
+ kept.add(match.ft);
881
+ }
882
+ testsToRun = testsToRun.filter((ft) => kept.has(ft));
883
+ if (testsToRun.length === 0) {
884
+ console.error(`\n${colors.red}โŒ No tests match --only-id ${selectorIds.join(", ")}${colors.reset}\n`);
885
+ process.exit(1);
886
+ }
887
+ }
814
888
  if (testsToRun.length === 0) {
815
889
  if (options.filter || hasTags) {
816
890
  const parts = [];
@@ -952,6 +1026,15 @@ export async function runCommand(target, options = {}) {
952
1026
  delete process.env["GLUBEAN_RUNNER_BOOTSTRAP_INPUT_MAP"];
953
1027
  delete process.env["GLUBEAN_RUNNER_FORCE_STANDALONE_IDS"];
954
1028
  }
1029
+ // B2 M3 โ€” hand the resolved selector set to the harness subprocess (it applies
1030
+ // the precise per-row filter at runtime). Clear it otherwise so a stale value
1031
+ // never leaks across in-process invocations (parity with the input maps above).
1032
+ if (onlySelectors.length > 0) {
1033
+ process.env["GLUBEAN_RUNNER_ONLY_SELECTORS"] = JSON.stringify(onlySelectors);
1034
+ }
1035
+ else {
1036
+ delete process.env["GLUBEAN_RUNNER_ONLY_SELECTORS"];
1037
+ }
955
1038
  if (options.pick) {
956
1039
  process.env.GLUBEAN_PICK = options.pick;
957
1040
  console.log(`${colors.dim} pick: ${options.pick}${colors.reset}`);
@@ -1024,6 +1107,7 @@ export async function runCommand(target, options = {}) {
1024
1107
  let currentTestItems;
1025
1108
  let testId = "";
1026
1109
  let testName = "";
1110
+ let testRowIndex = undefined;
1027
1111
  let testItem = null;
1028
1112
  let startTime = Date.now();
1029
1113
  let testEvents = [];
@@ -1089,6 +1173,7 @@ export async function runCommand(target, options = {}) {
1089
1173
  success: skippedClean ? true : finalSuccess,
1090
1174
  durationMs: duration,
1091
1175
  groupId: testItem?.meta.groupId,
1176
+ rowIndex: testRowIndex,
1092
1177
  });
1093
1178
  addLogEntry("result", skippedClean ? "SKIPPED" : finalSuccess ? "PASSED" : "FAILED", {
1094
1179
  duration,
@@ -1406,6 +1491,10 @@ export async function runCommand(target, options = {}) {
1406
1491
  (currentTestItems ? findFileTestByRuntimeId(currentTestItems, event.id) : undefined);
1407
1492
  testId = event.id;
1408
1493
  testName = entry?.test.meta.name || event.name || event.id;
1494
+ // rowIndex (B2 M3): the runtime start event is authoritative for the
1495
+ // per-row `.each` index (static discovery only sees the template id,
1496
+ // so it carries no rowIndex). undefined for non-each tests.
1497
+ testRowIndex = event.rowIndex;
1409
1498
  testItem = entry?.test || null;
1410
1499
  startTime = Date.now();
1411
1500
  testEvents = [];
@@ -1871,6 +1960,12 @@ export async function runCommand(target, options = {}) {
1871
1960
  success: r.success,
1872
1961
  durationMs: r.durationMs,
1873
1962
  events: r.events,
1963
+ // B2 M3 โ€” persist rowIndex + filePath so `--rerun-failed` can reconstruct
1964
+ // the failed `{id, rowIndex}` selector set and narrow to the failed files.
1965
+ // filePath is relative to rootDir (the stable .glubean/ basis), NOT cwd, so
1966
+ // rerun resolves correctly when run from a different working directory.
1967
+ ...(r.rowIndex !== undefined && { rowIndex: r.rowIndex }),
1968
+ filePath: relative(rootDir, r.filePath),
1874
1969
  })),
1875
1970
  ...(thresholdSummary && { thresholds: thresholdSummary }),
1876
1971
  ...(options.meta && Object.keys(options.meta).length > 0 && { customMetadata: options.meta }),
@@ -1924,29 +2019,30 @@ export async function runCommand(target, options = {}) {
1924
2019
  }
1925
2020
  }
1926
2021
  // โ”€โ”€ 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
- }
2022
+ // This run's exact screenshot files, pulled from the `browser:screenshot`
2023
+ // event stream. Doubles as the upload whitelist below so `--upload` attaches
2024
+ // only THIS run's screenshots, not every file in the shared dir (ART1).
2025
+ const screenshotPaths = [];
2026
+ for (const run of collectedRuns) {
2027
+ for (const event of run.events) {
2028
+ if (event.type !== "event")
2029
+ continue;
2030
+ const ev = event.data;
2031
+ if (ev.type === "browser:screenshot" && typeof ev.data?.path === "string") {
2032
+ screenshotPaths.push(resolve(rootDir, ev.data.path));
1937
2033
  }
1938
2034
  }
1939
- if (screenshotPaths.length > 0) {
1940
- for (const p of screenshotPaths) {
1941
- console.log(`${colors.dim}Screenshot: ${colors.reset}${p}`);
1942
- }
1943
- console.log();
2035
+ }
2036
+ if (screenshotPaths.length > 0) {
2037
+ for (const p of screenshotPaths) {
2038
+ console.log(`${colors.dim}Screenshot: ${colors.reset}${p}`);
1944
2039
  }
2040
+ console.log();
1945
2041
  }
1946
2042
  // โ”€โ”€ Cloud upload โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1947
2043
  if (options.upload) {
1948
2044
  const { resolveToken, resolveProjectId, resolveApiUrl } = await import("../lib/auth.js");
1949
- const { uploadToCloud } = await import("../lib/upload.js");
2045
+ const { uploadToCloud, removeUploadedScreenshots } = await import("../lib/upload.js");
1950
2046
  const authOpts = {
1951
2047
  token: options.token,
1952
2048
  project: options.project,
@@ -2096,7 +2192,27 @@ export async function runCommand(target, options = {}) {
2096
2192
  targetId,
2097
2193
  envFile: effectiveRun.envFile,
2098
2194
  rootDir,
2195
+ // Upload only THIS run's screenshots (whitelist), not the whole
2196
+ // shared `.glubean/screenshots` dir which accumulates prior runs.
2197
+ screenshotPaths,
2099
2198
  });
2199
+ // ART1-B โ€” the shared screenshots dir only ever grows, so once the
2200
+ // Cloud confirmed it received this run's screenshots, delete the local
2201
+ // copies. Deletes ONLY uploadedFiles โˆฉ screenshotPaths (both server-
2202
+ // confirmed and provably this run's), realpath-contained to
2203
+ // `.glubean/screenshots`, and only while the on-disk file still has
2204
+ // its upload-time stat identity (re-checked just before each unlink)
2205
+ // โ€” a failed/partial upload keeps its files, and a concurrently
2206
+ // recreated path is kept.
2207
+ if (!options.keepLocal &&
2208
+ uploadReceipt.artifactUpload.status === "uploaded" &&
2209
+ uploadReceipt.artifactUpload.uploadedFiles?.length &&
2210
+ screenshotPaths.length > 0) {
2211
+ const { removed } = await removeUploadedScreenshots(rootDir, screenshotPaths, uploadReceipt.artifactUpload.uploadedFiles);
2212
+ if (removed > 0) {
2213
+ console.log(`${colors.dim}Cleaned up ${removed} uploaded screenshot(s) from .glubean/screenshots (use --keep-local to keep them)${colors.reset}`);
2214
+ }
2215
+ }
2100
2216
  if (options.uploadReceiptJson) {
2101
2217
  const receiptPath = resolveOutputPath(options.uploadReceiptJson, process.cwd());
2102
2218
  await mkdir(dirname(receiptPath), { recursive: true });