playwright-test-explorer 0.0.10 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,12 +36,15 @@ pw-test-explorer tree
36
36
 
37
37
  # 保存结果到文件
38
38
  pw-test-explorer tree -o output.json
39
+
40
+ # 根据 Git 变更增量更新已有树(缓存文件及其 state 文件需在构建间保留)
41
+ pw-test-explorer tree --incremental -o output.json
39
42
  ```
40
43
 
41
44
  ### API 使用
42
45
 
43
46
  ```typescript
44
- import { listTestFile, listTestCase, treeStructure } from 'playwright-test-explorer';
47
+ import { listTestFile, listTestCase, treeStructure, updateTreeStructure } from 'playwright-test-explorer';
45
48
 
46
49
  // 列出测试文件
47
50
  await listTestFile('/path/to/project', 'playwright.config.ts');
@@ -51,8 +54,21 @@ await listTestCase('/path/to/project', 'playwright.config.ts', ['tests/example.s
51
54
 
52
55
  // 生成测试树
53
56
  await treeStructure('/path/to/project', 'playwright.config.ts');
57
+
58
+ // 根据缓存记录的 Git 提交之后的变更更新测试树;状态文件默认为 <output>.state.json。
59
+ await updateTreeStructure('/path/to/project', 'playwright.config.ts', '/path/to/project/test-tree.json');
54
60
  ```
55
61
 
62
+ `tree --incremental` 在没有有效缓存时全量构建测试树。后续运行根据缓存记录的 Git 提交之后的变更,重新收集发生变化的测试文件,并移除已删除或重命名文件的旧节点。CI 构建之间需要同时保留测试树输出文件及其配套的 `.state.json` 状态文件。配置或参与缓存校验的环境变量发生变化时,缓存失效并触发全量重建。
63
+
64
+ 增量比较只考虑已提交的 Git 变更,假设工作区内容与记录的提交一致。工作区可以是 Git 仓库的子目录。非测试文件发生变化时,会保守地触发全量重建。对同一组输出文件和状态文件的更新应串行执行。
65
+
66
+ 缓存默认跟踪 `CI`、`NODE_ENV`、`NODE_OPTIONS`、`TZ`,以及配置文件内容、工作区路径和 Node.js 版本。CI 任务 ID 等临时变量不会使缓存失效。如果配置或测试定义依赖其他环境变量,需要通过 `--cache-env DOC,AI` 声明变量名,或通过 API 的第五个参数传入 `{ environmentKeys: ['DOC', 'AI'] }`。这些项目自定义变量仅在显式声明后参与缓存校验。状态文件只保存计算后的哈希值,不保存环境变量的原始值。升级后,版本 1 的旧缓存会触发一次全量重建。
67
+
68
+ `tree` 和 `list-tests` 输出的报告文件路径均相对于 Playwright 的 `rootDir`,即使各项目配置了不同的 `testDir`,也使用这一基准。
69
+
70
+ 全量构建通过 Playwright 的 `test --list` 和本库内置的 reporter 收集测试。测试文件在一个子进程中加载,仅将最终测试树传回主进程,省去 test server 模式下 loader 向 server 传输依赖缓存的开销。收集过程不会执行全局 setup、测试正文或用户配置的 reporter。增量收集继续使用 test server。
71
+
56
72
  ## 文档
57
73
 
58
74
  - [功能规格说明](./SPEC.md) - 完整的功能需求和 API 文档
@@ -1,12 +1,13 @@
1
1
  import { createRequire } from "node:module";
2
2
  import path from "path";
3
- import { spawn } from "child_process";
3
+ import { execFile, fork, spawn } from "child_process";
4
4
  import WebSocket from "ws";
5
+ import { fileURLToPath } from "url";
5
6
  import fs from "fs/promises";
6
-
7
- //#region rolldown:runtime
7
+ import { createHash } from "crypto";
8
+ import { promisify } from "util";
9
+ //#region \0rolldown/runtime.js
8
10
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
9
-
10
11
  //#endregion
11
12
  //#region src/upstream/teleReceiver.ts
12
13
  var TeleReporterReceiver = class {
@@ -406,7 +407,6 @@ function parseRegexPatterns(patterns) {
406
407
  return new RegExp(p.r.source, p.r.flags);
407
408
  });
408
409
  }
409
-
410
410
  //#endregion
411
411
  //#region src/upstream/events.ts
412
412
  let Disposable;
@@ -444,8 +444,8 @@ var EventEmitter = class {
444
444
  });
445
445
  if (!dispatch) return;
446
446
  for (let index = 0; index < this._deliveryQueue.length; index++) {
447
- const { listener, event: event$1 } = this._deliveryQueue[index];
448
- listener.call(null, event$1);
447
+ const { listener, event } = this._deliveryQueue[index];
448
+ listener.call(null, event);
449
449
  }
450
450
  this._deliveryQueue = void 0;
451
451
  }
@@ -454,7 +454,6 @@ var EventEmitter = class {
454
454
  if (this._deliveryQueue) this._deliveryQueue = [];
455
455
  }
456
456
  };
457
-
458
457
  //#endregion
459
458
  //#region src/upstream/testServerConnection.ts
460
459
  var WebSocketTestServerTransport = class {
@@ -635,7 +634,6 @@ var TestServerConnection = class {
635
634
  } catch {}
636
635
  }
637
636
  };
638
-
639
637
  //#endregion
640
638
  //#region src/utils/pathUtils.ts
641
639
  /**
@@ -650,14 +648,6 @@ function escapeRegex(text) {
650
648
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
651
649
  }
652
650
  /**
653
- * Path resolution strategy for TeleReporterReceiver
654
- * For file paths (with extensions), return relative path (Playwright format)
655
- * For directory paths (testDir, outputDir, etc.), return absolute path
656
- */
657
- function resolvePathForFiles(rootDir, relativePath) {
658
- return /\.\w+$/.test(relativePath) ? relativePath : path.join(rootDir, relativePath);
659
- }
660
- /**
661
651
  * Standard path resolution - always returns absolute path
662
652
  */
663
653
  function resolveAbsolutePath(rootDir, relativePath) {
@@ -674,7 +664,6 @@ function findPlaywrightCli(workspaceFolder) {
674
664
  throw new Error("Playwright installation not found. Please install @playwright/test");
675
665
  }
676
666
  }
677
-
678
667
  //#endregion
679
668
  //#region src/utils/processUtils.ts
680
669
  /**
@@ -711,21 +700,9 @@ function createTestServerEnv(baseEnv, customEnv, cwd) {
711
700
  FORCE_COLOR: "1"
712
701
  }, cwd);
713
702
  }
714
-
715
703
  //#endregion
716
704
  //#region src/utils/reporterFactory.ts
717
705
  /**
718
- * Create TeleReporterReceiver for listing files
719
- * Uses special path resolution for preserving relative paths for files
720
- */
721
- function createFileListReceiver(reporter) {
722
- return new TeleReporterReceiver(reporter, {
723
- mergeProjects: true,
724
- mergeTestCases: true,
725
- resolvePath: resolvePathForFiles
726
- });
727
- }
728
- /**
729
706
  * Create TeleReporterReceiver for listing tests
730
707
  * Uses standard absolute path resolution
731
708
  */
@@ -742,7 +719,6 @@ function createTestListReceiver(reporter) {
742
719
  function processReport(receiver, report) {
743
720
  for (const message of report) receiver.dispatch(message);
744
721
  }
745
-
746
722
  //#endregion
747
723
  //#region src/playwrightTestServer.ts
748
724
  /**
@@ -781,11 +757,16 @@ var PlaywrightTestServer = class {
781
757
  }
782
758
  async listFiles() {
783
759
  const connection = await this._ensureConnection();
784
- const result = { projects: [] };
785
- const { report } = await connection.listFiles({});
760
+ const result = {
761
+ rootDir: this._config.workspaceFolder,
762
+ projects: []
763
+ };
764
+ const { report, status } = await connection.listFiles({});
786
765
  const workspaceFolder = this._config.workspaceFolder;
787
- processReport(createFileListReceiver({
788
- onConfigure: (_config) => {},
766
+ processReport(createTestListReceiver({
767
+ onConfigure: (_config) => {
768
+ result.rootDir = _config.rootDir;
769
+ },
789
770
  onBegin(rootSuite) {
790
771
  for (const projectSuite of rootSuite.suites) {
791
772
  const project = projectSuite.project();
@@ -797,24 +778,31 @@ var PlaywrightTestServer = class {
797
778
  use: project.use || {},
798
779
  files
799
780
  });
800
- for (const fileSuite of projectSuite.suites) files.push(fileSuite.location.file);
781
+ for (const fileSuite of projectSuite.suites) files.push(path.relative(result.rootDir, fileSuite.location.file).replace(/\\/g, "/"));
801
782
  }
802
783
  },
803
784
  onError(error) {
804
785
  result.error = error;
805
786
  }
806
787
  }), report);
788
+ if (result.error) throw new Error(result.error.message || "Playwright file collection failed");
789
+ if (status !== "passed") throw new Error(`Playwright file collection ${status}`);
807
790
  return result;
808
791
  }
809
792
  /**
810
793
  * Get raw test report without processing through TeleReporterReceiver
811
794
  * This preserves the complete test.describe hierarchy
812
795
  */
813
- async getRawTestReport(locations, token) {
796
+ async getRawTestReport(locations, token, projects) {
814
797
  if (token?.isCancellationRequested) return [];
815
- const connection = await this._ensureConnection();
816
- const escapedLocations = locations.map(escapeRegex);
817
- const { report } = await connection.listTests({ locations: escapedLocations });
798
+ const { report, status } = await (await this._ensureConnection()).listTests({
799
+ locations: locations?.map(escapeRegex),
800
+ projects
801
+ });
802
+ if (status !== "passed") {
803
+ const error = report.find((message) => message.method === "onError");
804
+ throw new Error(error?.params.error.message || `Playwright test collection ${status}`);
805
+ }
818
806
  return report;
819
807
  }
820
808
  async listTests(locations, reporter, token) {
@@ -912,206 +900,382 @@ var PlaywrightTestServer = class {
912
900
  this._disposeTestServer();
913
901
  }
914
902
  };
915
-
903
+ //#endregion
904
+ //#region src/utils/tree.ts
905
+ /** Consumes the report, reusing its suite/entry arrays instead of retaining two trees. */
906
+ function consumeTestReport(report) {
907
+ const root = {
908
+ type: "suite",
909
+ title: "Root",
910
+ children: []
911
+ };
912
+ const filePath = (file) => file.replace(/\\/g, "/");
913
+ function convertEntries(entries) {
914
+ for (let i = 0; i < entries.length; i++) {
915
+ const entry = entries[i];
916
+ entries[i] = entry.testId ? {
917
+ type: "test",
918
+ title: entry.title,
919
+ testId: entry.testId,
920
+ location: {
921
+ ...entry.location,
922
+ file: filePath(entry.location.file)
923
+ },
924
+ tags: entry.tags || [],
925
+ annotations: entry.annotations || [],
926
+ expectedStatus: "passed",
927
+ timeout: 0
928
+ } : {
929
+ type: "suite",
930
+ title: entry.title,
931
+ file: entry.location ? filePath(entry.location.file) : void 0,
932
+ children: convertEntries(entry.entries || [])
933
+ };
934
+ }
935
+ return entries;
936
+ }
937
+ const error = report.find((message) => message.method === "onError");
938
+ if (error) throw new Error(error.params.error.message || "Playwright test collection failed");
939
+ for (let i = 0; i < report.length; i++) {
940
+ const message = report[i];
941
+ report[i] = void 0;
942
+ if (message.method !== "onProject") continue;
943
+ const project = message.params.project;
944
+ root.children.push({
945
+ type: "suite",
946
+ title: project.name,
947
+ children: convertEntries(project.suites || [])
948
+ });
949
+ }
950
+ report.length = 0;
951
+ return root;
952
+ }
953
+ //#endregion
954
+ //#region src/collectTree.ts
955
+ /** CLI list mode loads tests in one process, without transferring the loader's dependency graph. */
956
+ function collectFullTree(workspaceFolder, configFile, token) {
957
+ if (token?.isCancellationRequested) return Promise.reject(/* @__PURE__ */ new Error("Test collection cancelled"));
958
+ workspaceFolder = path.resolve(workspaceFolder);
959
+ const cli = findPlaywrightCli(workspaceFolder);
960
+ const reporter = fileURLToPath(new URL("./treeReporter.mjs", import.meta.url));
961
+ return new Promise((resolve, reject) => {
962
+ const child = fork(cli, [
963
+ "test",
964
+ "--list",
965
+ "--reporter",
966
+ reporter,
967
+ "--project",
968
+ "*",
969
+ "--repeat-each",
970
+ "1",
971
+ "--retries",
972
+ "0",
973
+ "--global-timeout",
974
+ "0",
975
+ "--pass-with-no-tests",
976
+ "-c",
977
+ path.resolve(workspaceFolder, configFile)
978
+ ], {
979
+ cwd: workspaceFolder,
980
+ env: createTestServerEnv(process.env, {}, workspaceFolder),
981
+ execArgv: [],
982
+ serialization: "advanced",
983
+ stdio: [
984
+ "ignore",
985
+ "ignore",
986
+ "pipe",
987
+ "ipc"
988
+ ]
989
+ });
990
+ let result;
991
+ let stderr = "";
992
+ child.stderr.on("data", (chunk) => {
993
+ stderr = (stderr + chunk).slice(-64 * 1024);
994
+ });
995
+ child.on("message", (message) => {
996
+ if (message && typeof message === "object" && "type" in message && message.type === "test-explorer:tree") result = message;
997
+ });
998
+ const cancellation = token?.onCancellationRequested(() => child.kill());
999
+ child.once("error", reject);
1000
+ child.once("close", (code, signal) => {
1001
+ cancellation?.dispose();
1002
+ if (token?.isCancellationRequested) reject(/* @__PURE__ */ new Error("Test collection cancelled"));
1003
+ else if (code !== 0 || !result?.tree || result.error) reject(new Error(result?.error || stderr.trim() || `Playwright test collection failed (${signal || code})`));
1004
+ else resolve(result.tree);
1005
+ });
1006
+ });
1007
+ }
916
1008
  //#endregion
917
1009
  //#region src/api.ts
918
- async function listTestFile(workspaceFolder = process.cwd(), configFile = "playwright.config.ts", outputFile) {
919
- const testServer = new PlaywrightTestServer({
1010
+ const execFileAsync = promisify(execFile);
1011
+ const GIT_MAX_BUFFER = 32 * 1024 * 1024;
1012
+ const COLLECTION_ENV_KEYS = [
1013
+ "CI",
1014
+ "NODE_ENV",
1015
+ "NODE_OPTIONS",
1016
+ "TZ"
1017
+ ];
1018
+ async function runGit(workspaceFolder, args) {
1019
+ const { stdout } = await execFileAsync("git", args, {
1020
+ cwd: workspaceFolder,
1021
+ encoding: "buffer",
1022
+ maxBuffer: GIT_MAX_BUFFER
1023
+ });
1024
+ return stdout;
1025
+ }
1026
+ function sha256(value) {
1027
+ return createHash("sha256").update(value).digest("hex");
1028
+ }
1029
+ async function incrementalInputHash(workspaceFolder, configFile, options) {
1030
+ const configPath = path.resolve(workspaceFolder, configFile);
1031
+ const environment = [...new Set([...COLLECTION_ENV_KEYS, ...options.environmentKeys ?? []])].sort().map((key) => [key, process.env[key]]);
1032
+ return createHash("sha256").update(JSON.stringify([
920
1033
  workspaceFolder,
921
- configFile: path.join(workspaceFolder, configFile)
1034
+ configPath,
1035
+ process.version,
1036
+ environment
1037
+ ])).update("\0").update(await fs.readFile(configPath)).digest("hex");
1038
+ }
1039
+ function parseGitNameStatus(buffer) {
1040
+ const fields = buffer.toString("utf8").split("\0");
1041
+ const changes = [];
1042
+ for (let i = 0; i < fields.length;) {
1043
+ const status = fields[i++];
1044
+ if (!status) continue;
1045
+ if (status.startsWith("R") || status.startsWith("C")) changes.push({
1046
+ status: status[0],
1047
+ from: fields[i++],
1048
+ to: fields[i++]
1049
+ });
1050
+ else changes.push({
1051
+ status: status[0],
1052
+ path: fields[i++]
1053
+ });
1054
+ }
1055
+ return changes;
1056
+ }
1057
+ function normalizePath(file) {
1058
+ return file.replace(/\\/g, "/");
1059
+ }
1060
+ function isWithin(directory, file) {
1061
+ const relative = path.relative(directory, file);
1062
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
1063
+ }
1064
+ /** Share untouched branches; only replace affected project/file arrays. */
1065
+ function mergeIncrementalTree(cachedTree, changedTree, updatedFilesByProject) {
1066
+ const changedProjects = new Map((changedTree.children ?? []).map((project) => [project.title, project]));
1067
+ const projects = [...cachedTree.children ?? []];
1068
+ const knownProjects = new Set(projects.map((project) => project.title));
1069
+ for (const project of changedTree.children ?? []) if (!knownProjects.has(project.title)) projects.push({
1070
+ ...project,
1071
+ children: []
922
1072
  });
1073
+ return {
1074
+ ...cachedTree,
1075
+ children: projects.map((project) => {
1076
+ const files = updatedFilesByProject.get(project.title);
1077
+ if (!files?.size) return project;
1078
+ const children = (project.children ?? []).filter((node) => !files.has(normalizePath(node.file || "")));
1079
+ for (const node of changedProjects.get(project.title)?.children ?? []) if (files.has(normalizePath(node.file || ""))) children.push(node);
1080
+ children.sort((a, b) => (a.file || "") < (b.file || "") ? -1 : (a.file || "") > (b.file || "") ? 1 : 0);
1081
+ return {
1082
+ ...project,
1083
+ children
1084
+ };
1085
+ }).filter((project) => project.children?.length)
1086
+ };
1087
+ }
1088
+ async function writeFileAtomic(file, value) {
1089
+ const temporary = `${file}.${process.pid}.tmp`;
923
1090
  try {
924
- const output = { projects: (await testServer.listFiles()).projects.map((project) => ({
925
- ...project,
926
- files: project.files.map((filePath) => filePath.replace(/\\/g, "/"))
927
- })) };
928
- const jsonString = JSON.stringify(output, null, 2);
929
- if (outputFile) {
930
- await fs.writeFile(outputFile, jsonString, "utf-8");
931
- console.log(`Results saved to: ${outputFile}`);
932
- } else console.log(jsonString);
933
- testServer.close();
934
- } catch (error) {
935
- console.error("Error:", error);
936
- testServer.close();
1091
+ await fs.writeFile(temporary, value);
1092
+ await fs.rename(temporary, file);
1093
+ } finally {
1094
+ await fs.unlink(temporary).catch(() => {});
937
1095
  }
938
1096
  }
939
- async function listTestCase(workspaceFolder = process.cwd(), configFile = "playwright.config.ts", relativePaths, outputFile) {
940
- const testServer = new PlaywrightTestServer({
941
- workspaceFolder,
942
- configFile: path.join(workspaceFolder, configFile)
1097
+ async function readIncrementalState(stateFile) {
1098
+ try {
1099
+ return JSON.parse(await fs.readFile(stateFile, "utf8"));
1100
+ } catch {
1101
+ return;
1102
+ }
1103
+ }
1104
+ async function isGitAncestor(workspaceFolder, base, head) {
1105
+ if (base === head) return true;
1106
+ try {
1107
+ await runGit(workspaceFolder, [
1108
+ "merge-base",
1109
+ "--is-ancestor",
1110
+ base,
1111
+ head
1112
+ ]);
1113
+ return true;
1114
+ } catch {
1115
+ return false;
1116
+ }
1117
+ }
1118
+ function isTestFile(file) {
1119
+ return /\.(?:spec|test)\.(?:[cm]?[jt]sx?)$/i.test(file);
1120
+ }
1121
+ function createServer(workspaceFolder, configFile) {
1122
+ return new PlaywrightTestServer({
1123
+ workspaceFolder: path.resolve(workspaceFolder),
1124
+ configFile: path.resolve(workspaceFolder, configFile)
943
1125
  });
1126
+ }
1127
+ /** Update committed test changes; non-test changes conservatively rebuild the tree. */
1128
+ async function updateTreeStructure(workspaceFolder, configFile, outputFile, stateFile = `${outputFile}.state.json`, options = {}) {
1129
+ workspaceFolder = path.resolve(workspaceFolder);
1130
+ const head = (await runGit(workspaceFolder, ["rev-parse", "HEAD"])).toString("utf8").trim();
1131
+ const inputHash = await incrementalInputHash(workspaceFolder, configFile, options);
1132
+ const state = await readIncrementalState(stateFile);
1133
+ let treeContent = await fs.readFile(outputFile).catch(() => void 0);
1134
+ const validCache = state?.version === 2 && state.inputHash === inputHash && treeContent !== void 0 && state.treeHash === sha256(treeContent) && await isGitAncestor(workspaceFolder, state.commit, head);
1135
+ const save = async (tree) => {
1136
+ const content = `${JSON.stringify(tree, null, 2)}\n`;
1137
+ const nextState = {
1138
+ version: 2,
1139
+ commit: head,
1140
+ inputHash,
1141
+ treeHash: sha256(content)
1142
+ };
1143
+ await writeFileAtomic(outputFile, content);
1144
+ await writeFileAtomic(stateFile, `${JSON.stringify(nextState, null, 2)}\n`);
1145
+ };
1146
+ if (validCache && state.commit === head) return {
1147
+ mode: "unchanged",
1148
+ updatedFiles: 0,
1149
+ deletedFiles: 0
1150
+ };
1151
+ const gitRoot = (await runGit(workspaceFolder, ["rev-parse", "--show-toplevel"])).toString("utf8").trim();
1152
+ const changes = validCache ? parseGitNameStatus(await runGit(gitRoot, [
1153
+ "diff",
1154
+ "--no-relative",
1155
+ "--name-status",
1156
+ "-z",
1157
+ "--find-renames",
1158
+ `${state.commit}...${head}`
1159
+ ])) : [];
1160
+ if (validCache && !changes.length) {
1161
+ await writeFileAtomic(stateFile, `${JSON.stringify({
1162
+ ...state,
1163
+ commit: head
1164
+ }, null, 2)}\n`);
1165
+ return {
1166
+ mode: "unchanged",
1167
+ updatedFiles: 0,
1168
+ deletedFiles: 0
1169
+ };
1170
+ }
1171
+ const needsFullBuild = !validCache || changes.some((change) => ("from" in change ? [change.from, change.to] : [change.path]).some((file) => !isTestFile(file)));
1172
+ let cachedTree;
1173
+ if (!needsFullBuild) try {
1174
+ cachedTree = JSON.parse(treeContent.toString("utf8"));
1175
+ if (cachedTree?.type !== "suite" || !Array.isArray(cachedTree.children)) cachedTree = void 0;
1176
+ } catch {}
1177
+ treeContent = void 0;
1178
+ if (!cachedTree) {
1179
+ await save(await collectFullTree(workspaceFolder, configFile));
1180
+ return {
1181
+ mode: "full",
1182
+ updatedFiles: 0,
1183
+ deletedFiles: 0
1184
+ };
1185
+ }
1186
+ const server = createServer(workspaceFolder, configFile);
944
1187
  try {
945
- const token = new CancellationToken();
946
- const expandedPaths = [];
947
- for (const relativePath of relativePaths) {
948
- const normalizedInputPath = relativePath.replace(/\\/g, "/").replace(/\/$/, "");
949
- const absolutePath = path.join(workspaceFolder, relativePath);
950
- try {
951
- if ((await fs.stat(absolutePath)).isDirectory()) {
952
- const files = await testServer.listFiles();
953
- for (const project of files.projects) {
954
- const testDirRelative = project.testDir || "";
955
- for (const file of project.files) {
956
- const fullPath = testDirRelative ? path.join(testDirRelative, file).replace(/\\/g, "/") : file.replace(/\\/g, "/");
957
- if (fullPath.startsWith(normalizedInputPath + "/") || fullPath === normalizedInputPath) expandedPaths.push(fullPath);
958
- }
959
- }
960
- } else expandedPaths.push(normalizedInputPath);
961
- } catch {
962
- expandedPaths.push(normalizedInputPath);
1188
+ const listed = await server.listFiles();
1189
+ const affected = /* @__PURE__ */ new Set();
1190
+ const deleted = /* @__PURE__ */ new Set();
1191
+ const candidates = /* @__PURE__ */ new Set();
1192
+ for (const change of changes) {
1193
+ if ("from" in change && change.status === "R") {
1194
+ const oldFile = normalizePath(path.relative(listed.rootDir, path.resolve(gitRoot, change.from)));
1195
+ affected.add(oldFile);
1196
+ deleted.add(oldFile);
963
1197
  }
1198
+ const file = normalizePath(path.relative(listed.rootDir, path.resolve(gitRoot, "to" in change ? change.to : change.path)));
1199
+ affected.add(file);
1200
+ if (change.status === "D") deleted.add(file);
1201
+ else candidates.add(file);
964
1202
  }
965
- if (expandedPaths.length === 0) {
966
- testServer.close();
967
- return;
968
- }
969
- const report = await testServer.getRawTestReport(expandedPaths, token);
970
- function parseEntry(entry, testDir) {
971
- if (entry.testId) {
972
- const filePath = testDir ? path.join(testDir, entry.location.file).replace(/\\/g, "/") : entry.location.file.replace(/\\/g, "/");
973
- return {
974
- type: "test",
975
- title: entry.title,
976
- testId: entry.testId,
977
- location: {
978
- file: filePath,
979
- line: entry.location.line,
980
- column: entry.location.column
981
- },
982
- tags: entry.tags || [],
983
- annotations: entry.annotations || [],
984
- expectedStatus: "passed",
985
- timeout: 0
986
- };
987
- } else {
988
- const filePath = entry.location && testDir ? path.join(testDir, entry.location.file).replace(/\\/g, "/") : entry.location?.file.replace(/\\/g, "/");
989
- const node = {
990
- type: "suite",
991
- title: entry.title,
992
- file: filePath,
993
- children: []
994
- };
995
- if (entry.entries && Array.isArray(entry.entries)) for (const childEntry of entry.entries) node.children.push(parseEntry(childEntry, testDir));
996
- return node;
1203
+ const pathsToCollect = /* @__PURE__ */ new Set();
1204
+ const updatedByProject = /* @__PURE__ */ new Map();
1205
+ const deletedFiles = /* @__PURE__ */ new Set();
1206
+ for (const project of cachedTree.children ?? []) {
1207
+ const files = /* @__PURE__ */ new Set();
1208
+ for (const node of project.children ?? []) {
1209
+ const file = normalizePath(node.file || "");
1210
+ if (affected.has(file)) files.add(file);
1211
+ if (deleted.has(file)) deletedFiles.add(file);
997
1212
  }
1213
+ updatedByProject.set(project.title, files);
998
1214
  }
999
- const rootNode = {
1000
- type: "suite",
1001
- title: "Root",
1002
- children: []
1003
- };
1004
- for (const message of report) if (message.method === "onProject") {
1005
- const project = message.params.project;
1006
- const projectNode = {
1007
- type: "suite",
1008
- title: project.name,
1009
- children: []
1010
- };
1011
- let testDir = "";
1012
- if (project.testDir) testDir = path.isAbsolute(project.testDir) ? path.relative(workspaceFolder, project.testDir).replace(/\\/g, "/") : project.testDir.replace(/\\/g, "/");
1013
- if (project.suites && Array.isArray(project.suites)) for (const suite of project.suites) {
1014
- const suiteNode = parseEntry(suite, testDir);
1015
- projectNode.children.push(suiteNode);
1215
+ for (const project of listed.projects) {
1216
+ const files = updatedByProject.get(project.name) ?? /* @__PURE__ */ new Set();
1217
+ for (const file of project.files) {
1218
+ if (!candidates.has(file)) continue;
1219
+ files.add(file);
1220
+ pathsToCollect.add(path.resolve(listed.rootDir, file));
1016
1221
  }
1017
- rootNode.children.push(projectNode);
1222
+ updatedByProject.set(project.name, files);
1018
1223
  }
1019
- const jsonString = JSON.stringify(rootNode, null, 2);
1020
- if (outputFile) {
1021
- await fs.writeFile(outputFile, jsonString, "utf-8");
1022
- console.log(`Results saved to: ${outputFile}`);
1023
- } else console.log(jsonString);
1024
- testServer.close();
1025
- } catch (error) {
1026
- console.error("Error:", error);
1027
- testServer.close();
1224
+ const partialTree = pathsToCollect.size ? consumeTestReport(await server.getRawTestReport([...pathsToCollect])) : consumeTestReport([]);
1225
+ const merged = mergeIncrementalTree(cachedTree, partialTree, updatedByProject);
1226
+ const projectOrder = new Map(listed.projects.map((project, index) => [project.name, index]));
1227
+ merged.children.sort((a, b) => projectOrder.get(a.title) - projectOrder.get(b.title));
1228
+ await save(merged);
1229
+ const updatedFiles = pathsToCollect.size;
1230
+ return {
1231
+ mode: updatedFiles || deletedFiles.size ? "incremental" : "unchanged",
1232
+ updatedFiles,
1233
+ deletedFiles: deletedFiles.size
1234
+ };
1235
+ } finally {
1236
+ server.close();
1028
1237
  }
1029
1238
  }
1030
- async function treeStructure(workspaceFolder = process.cwd(), configFile = "playwright.config.ts", cancellationToken, outputFile) {
1031
- const testServer = new PlaywrightTestServer({
1032
- workspaceFolder,
1033
- configFile: path.join(workspaceFolder, configFile)
1034
- });
1239
+ async function outputJson(value, outputFile) {
1240
+ const content = JSON.stringify(value, null, 2);
1241
+ if (outputFile) {
1242
+ await fs.writeFile(outputFile, content, "utf8");
1243
+ console.log(`Results saved to: ${outputFile}`);
1244
+ } else console.log(content);
1245
+ }
1246
+ async function listTestFile(workspaceFolder = process.cwd(), configFile = "playwright.config.ts", outputFile) {
1247
+ const server = createServer(workspaceFolder, configFile);
1035
1248
  try {
1036
- const token = cancellationToken || new CancellationToken();
1037
- const files = await testServer.listFiles();
1038
- if (files.projects.length === 0) {
1039
- testServer.close();
1040
- return;
1041
- }
1042
- const allRelativePaths = [];
1043
- for (const project of files.projects) if (project.files.length > 0) {
1044
- const testDirRelative = project.testDir || "";
1045
- const relativePaths = project.files.map((file) => {
1046
- return testDirRelative ? path.join(testDirRelative, file).replace(/\\/g, "/") : file.replace(/\\/g, "/");
1047
- });
1048
- allRelativePaths.push(...relativePaths);
1049
- }
1050
- if (allRelativePaths.length === 0) {
1051
- testServer.close();
1052
- return;
1053
- }
1054
- const report = await testServer.getRawTestReport(allRelativePaths, token);
1055
- function parseEntry(entry, testDir) {
1056
- if (entry.testId) {
1057
- const filePath = entry.location.file.replace(/\\/g, "/");
1058
- return {
1059
- type: "test",
1060
- title: entry.title,
1061
- testId: entry.testId,
1062
- location: {
1063
- file: filePath,
1064
- line: entry.location.line,
1065
- column: entry.location.column
1066
- },
1067
- tags: entry.tags || [],
1068
- annotations: entry.annotations || [],
1069
- expectedStatus: "passed",
1070
- timeout: 0
1071
- };
1072
- } else {
1073
- const filePath = entry.location?.file.replace(/\\/g, "/");
1074
- const node = {
1075
- type: "suite",
1076
- title: entry.title,
1077
- file: filePath,
1078
- children: []
1079
- };
1080
- if (entry.entries && Array.isArray(entry.entries)) for (const childEntry of entry.entries) node.children.push(parseEntry(childEntry, testDir));
1081
- return node;
1082
- }
1083
- }
1084
- const rootNode = {
1085
- type: "suite",
1086
- title: "Root",
1087
- children: []
1088
- };
1089
- for (const message of report) if (message.method === "onProject") {
1090
- const project = message.params.project;
1091
- const projectNode = {
1092
- type: "suite",
1093
- title: project.name,
1094
- children: []
1095
- };
1096
- let testDir = "";
1097
- if (project.testDir) testDir = path.isAbsolute(project.testDir) ? path.relative(workspaceFolder, project.testDir).replace(/\\/g, "/") : project.testDir.replace(/\\/g, "/");
1098
- if (project.suites && Array.isArray(project.suites)) for (const suite of project.suites) {
1099
- const suiteNode = parseEntry(suite, testDir);
1100
- projectNode.children.push(suiteNode);
1101
- }
1102
- rootNode.children.push(projectNode);
1249
+ const { projects } = await server.listFiles();
1250
+ await outputJson({ projects }, outputFile);
1251
+ } finally {
1252
+ server.close();
1253
+ }
1254
+ }
1255
+ async function listTestCase(workspaceFolder = process.cwd(), configFile = "playwright.config.ts", relativePaths, outputFile) {
1256
+ workspaceFolder = path.resolve(workspaceFolder);
1257
+ const server = createServer(workspaceFolder, configFile);
1258
+ try {
1259
+ const expandedPaths = /* @__PURE__ */ new Set();
1260
+ let listed;
1261
+ for (const input of relativePaths) {
1262
+ const absolutePath = path.resolve(workspaceFolder, normalizePath(input));
1263
+ if ((await fs.stat(absolutePath).catch(() => void 0))?.isDirectory()) {
1264
+ listed ??= await server.listFiles();
1265
+ for (const project of listed.projects) for (const file of project.files) {
1266
+ const absoluteFile = path.resolve(listed.rootDir, file);
1267
+ if (isWithin(absolutePath, absoluteFile)) expandedPaths.add(absoluteFile);
1268
+ }
1269
+ } else expandedPaths.add(absolutePath);
1103
1270
  }
1104
- const jsonString = JSON.stringify(rootNode, null, 2);
1105
- if (outputFile) {
1106
- await fs.writeFile(outputFile, jsonString, "utf-8");
1107
- console.log(`Results saved to: ${outputFile}`);
1108
- } else console.log(jsonString);
1109
- testServer.close();
1110
- } catch (error) {
1111
- console.error("Error:", error);
1112
- testServer.close();
1271
+ if (!expandedPaths.size) return;
1272
+ await outputJson(consumeTestReport(await server.getRawTestReport([...expandedPaths])), outputFile);
1273
+ } finally {
1274
+ server.close();
1113
1275
  }
1114
1276
  }
1115
-
1277
+ async function treeStructure(workspaceFolder = process.cwd(), configFile = "playwright.config.ts", cancellationToken, outputFile) {
1278
+ await outputJson(await collectFullTree(workspaceFolder, configFile, cancellationToken), outputFile);
1279
+ }
1116
1280
  //#endregion
1117
- export { CancellationToken as i, listTestFile as n, treeStructure as r, listTestCase as t };
1281
+ export { CancellationToken as a, updateTreeStructure as i, listTestFile as n, treeStructure as r, listTestCase as t };
package/dist/cli.mjs CHANGED
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { i as CancellationToken, n as listTestFile, r as treeStructure, t as listTestCase } from "./api-Bc2ZCCQ0.mjs";
2
+ import { a as CancellationToken, i as updateTreeStructure, n as listTestFile, r as treeStructure, t as listTestCase } from "./api-CvJUOXye.mjs";
3
3
  import path from "path";
4
4
  import { program } from "commander";
5
5
  import chalk from "chalk";
6
6
  import ora from "ora";
7
-
8
7
  //#region src/cli.ts
9
8
  /**
10
9
  * Playwright Test Explorer CLI
@@ -41,8 +40,8 @@ program.command("list-tests").description("List all test cases in specified file
41
40
  process.exit(1);
42
41
  }
43
42
  });
44
- program.command("tree").description("Display test structure as a tree").option("-w, --workspace <path>", "workspace folder path", process.cwd()).option("-c, --config <file>", "config file path (relative to workspace)", "playwright.config.ts").option("-o, --output <file>", "output file path to save JSON results").action(async (options) => {
45
- const spinner = ora("Building test tree...").start();
43
+ program.command("tree").description("Display test structure as a tree").option("-w, --workspace <path>", "workspace folder path", process.cwd()).option("-c, --config <file>", "config file path (relative to workspace)", "playwright.config.ts").option("-o, --output <file>", "output file path to save JSON results").option("--incremental", "update a saved tree from Git changes").option("--state <file>", "incremental state file path (defaults to <output>.state.json)").option("--cache-env <names>", "additional collection environment keys, comma-separated").action(async (options) => {
44
+ const spinner = ora(options.incremental ? "Updating test tree..." : "Building test tree...").start();
46
45
  try {
47
46
  const workspaceFolder = path.resolve(options.workspace);
48
47
  const token = new CancellationToken();
@@ -52,8 +51,15 @@ program.command("tree").description("Display test structure as a tree").option("
52
51
  process.exit(0);
53
52
  });
54
53
  const outputFile = options.output ? path.resolve(options.output) : void 0;
55
- await treeStructure(workspaceFolder, options.config, token, outputFile);
56
- spinner.succeed(chalk.green("Test tree built successfully"));
54
+ if (options.incremental) {
55
+ const incrementalOutput = outputFile || path.join(workspaceFolder, "test-case.json");
56
+ const stateFile = options.state ? path.resolve(options.state) : `${incrementalOutput}.state.json`;
57
+ const result = await updateTreeStructure(workspaceFolder, options.config, incrementalOutput, stateFile, { environmentKeys: options.cacheEnv?.split(",").map((key) => key.trim()).filter(Boolean) });
58
+ spinner.succeed(chalk.green(`Test tree ${result.mode}: ${result.updatedFiles} updated, ${result.deletedFiles} deleted`));
59
+ } else {
60
+ await treeStructure(workspaceFolder, options.config, token, outputFile);
61
+ spinner.succeed(chalk.green("Test tree built successfully"));
62
+ }
57
63
  } catch (error) {
58
64
  spinner.fail(chalk.red("Failed to build test tree"));
59
65
  console.error(chalk.red("Error:"), error instanceof Error ? error.message : String(error));
@@ -90,10 +96,12 @@ ${chalk.bold("Examples:")}
90
96
  ${chalk.cyan("# Save tree structure to JSON file")}
91
97
  $ pw-test-explorer tree -o tree.json
92
98
 
99
+ ${chalk.cyan("# Incrementally update a saved tree from Git changes")}
100
+ $ pw-test-explorer tree --incremental -o tree.json
101
+
93
102
  ${chalk.cyan("# Display tree for specific workspace")}
94
103
  $ pw-test-explorer tree -w /path/to/project
95
104
  `);
96
105
  program.parse();
97
-
98
106
  //#endregion
99
- export { };
107
+ export {};
package/dist/index.d.mts CHANGED
@@ -25,7 +25,6 @@ interface Event<T> {
25
25
  }
26
26
  //#endregion
27
27
  //#region src/playwrightTestServer.d.ts
28
-
29
28
  declare class CancellationToken {
30
29
  private _isCancelled;
31
30
  private _onCancelEmitter;
@@ -35,8 +34,19 @@ declare class CancellationToken {
35
34
  }
36
35
  //#endregion
37
36
  //#region src/api.d.ts
37
+ type IncrementalTreeOptions = {
38
+ /** Additional environment variables that affect configuration or test collection. */environmentKeys?: string[];
39
+ };
40
+ type IncrementalTreeResult = {
41
+ mode: 'full' | 'incremental' | 'unchanged';
42
+ updatedFiles: number;
43
+ deletedFiles: number;
44
+ };
45
+ /** Share untouched branches; only replace affected project/file arrays. */
46
+ /** Update committed test changes; non-test changes conservatively rebuild the tree. */
47
+ declare function updateTreeStructure(workspaceFolder: string, configFile: string, outputFile: string, stateFile?: string, options?: IncrementalTreeOptions): Promise<IncrementalTreeResult>;
38
48
  declare function listTestFile(workspaceFolder?: string, configFile?: string, outputFile?: string): Promise<void>;
39
49
  declare function listTestCase(workspaceFolder: string | undefined, configFile: string | undefined, relativePaths: string[], outputFile?: string): Promise<void>;
40
50
  declare function treeStructure(workspaceFolder?: string, configFile?: string, cancellationToken?: CancellationToken, outputFile?: string): Promise<void>;
41
51
  //#endregion
42
- export { listTestCase, listTestFile, treeStructure };
52
+ export { type IncrementalTreeOptions, listTestCase, listTestFile, treeStructure, updateTreeStructure };
package/dist/index.mjs CHANGED
@@ -1,3 +1,2 @@
1
- import { n as listTestFile, r as treeStructure, t as listTestCase } from "./api-Bc2ZCCQ0.mjs";
2
-
3
- export { listTestCase, listTestFile, treeStructure };
1
+ import { i as updateTreeStructure, n as listTestFile, r as treeStructure, t as listTestCase } from "./api-CvJUOXye.mjs";
2
+ export { listTestCase, listTestFile, treeStructure, updateTreeStructure };
@@ -0,0 +1,54 @@
1
+ import path from "node:path";
2
+ //#region src/treeReporter.mjs
3
+ var TreeReporter = class {
4
+ tree;
5
+ errors = [];
6
+ printsToStdio() {
7
+ return true;
8
+ }
9
+ onBegin(config, rootSuite) {
10
+ const location = (value) => value && {
11
+ ...value,
12
+ file: path.relative(config.rootDir, value.file).replace(/\\/g, "/")
13
+ };
14
+ const convert = (entry) => entry.type === "test" ? {
15
+ type: "test",
16
+ title: entry.title,
17
+ testId: entry.id,
18
+ location: location(entry.location),
19
+ tags: entry.tags || [],
20
+ annotations: entry.annotations.map((annotation) => ({
21
+ ...annotation,
22
+ location: location(annotation.location)
23
+ })),
24
+ expectedStatus: "passed",
25
+ timeout: 0
26
+ } : {
27
+ type: "suite",
28
+ title: entry.title,
29
+ file: location(entry.location)?.file,
30
+ children: entry.entries().map(convert)
31
+ };
32
+ this.tree = {
33
+ type: "suite",
34
+ title: "Root",
35
+ children: rootSuite.suites.map((project) => ({
36
+ type: "suite",
37
+ title: project.title,
38
+ children: project.entries().map(convert)
39
+ }))
40
+ };
41
+ }
42
+ onError(error) {
43
+ this.errors.push(error.message || error.value || "Playwright test collection failed");
44
+ }
45
+ async onEnd(result) {
46
+ await new Promise((resolve, reject) => process.send({
47
+ type: "test-explorer:tree",
48
+ tree: result.status === "passed" ? this.tree : void 0,
49
+ error: this.errors.join("\n")
50
+ }, (error) => error ? reject(error) : resolve()));
51
+ }
52
+ };
53
+ //#endregion
54
+ export { TreeReporter as default };
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "playwright-test-explorer",
3
- "version": "0.0.10",
3
+ "version": "0.0.13",
4
4
  "description": "A CLI tool and library for exploring Playwright tests",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "homepage": "https://github.com/author/library#readme",
7
+ "homepage": "https://github.com/li-zhixin/playwright-test-explorer#readme",
8
8
  "bugs": {
9
- "url": "https://github.com/author/library/issues"
9
+ "url": "https://github.com/li-zhixin/playwright-test-explorer/issues"
10
10
  },
11
11
  "repository": {
12
12
  "type": "git",
13
- "url": "git+https://github.com/author/library.git"
13
+ "url": "git+https://github.com/li-zhixin/playwright-test-explorer.git"
14
14
  },
15
15
  "author": "Author Name <author.name@mail.com>",
16
16
  "files": [
@@ -42,18 +42,18 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "chalk": "^5.6.2",
45
- "commander": "^14.0.2",
46
- "ora": "^9.0.0",
47
- "ws": "^8.18.3"
45
+ "commander": "^14.0.3",
46
+ "ora": "^9.3.0",
47
+ "ws": "^8.20.0"
48
48
  },
49
49
  "devDependencies": {
50
- "@playwright/test": "^1.57.0",
51
- "@types/node": "^24.10.1",
50
+ "@playwright/test": "^1.58.2",
51
+ "@types/node": "^25.5.0",
52
52
  "@types/ws": "^8.18.1",
53
- "@vitest/coverage-v8": "4.0.14",
54
- "bumpp": "^10.3.2",
55
- "tsdown": "^0.16.7",
56
- "typescript": "^5.9.3",
57
- "vitest": "^4.0.14"
53
+ "@vitest/coverage-v8": "4.1.2",
54
+ "bumpp": "^11.0.1",
55
+ "tsdown": "^0.21.7",
56
+ "typescript": "^6.0.2",
57
+ "vitest": "^4.1.2"
58
58
  }
59
59
  }