playwright-test-explorer 0.0.12 → 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,8 +1,11 @@
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";
7
+ import { createHash } from "crypto";
8
+ import { promisify } from "util";
6
9
  //#region \0rolldown/runtime.js
7
10
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
8
11
  //#endregion
@@ -645,14 +648,6 @@ function escapeRegex(text) {
645
648
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
646
649
  }
647
650
  /**
648
- * Path resolution strategy for TeleReporterReceiver
649
- * For file paths (with extensions), return relative path (Playwright format)
650
- * For directory paths (testDir, outputDir, etc.), return absolute path
651
- */
652
- function resolvePathForFiles(rootDir, relativePath) {
653
- return /\.\w+$/.test(relativePath) ? relativePath : path.join(rootDir, relativePath);
654
- }
655
- /**
656
651
  * Standard path resolution - always returns absolute path
657
652
  */
658
653
  function resolveAbsolutePath(rootDir, relativePath) {
@@ -708,17 +703,6 @@ function createTestServerEnv(baseEnv, customEnv, cwd) {
708
703
  //#endregion
709
704
  //#region src/utils/reporterFactory.ts
710
705
  /**
711
- * Create TeleReporterReceiver for listing files
712
- * Uses special path resolution for preserving relative paths for files
713
- */
714
- function createFileListReceiver(reporter) {
715
- return new TeleReporterReceiver(reporter, {
716
- mergeProjects: true,
717
- mergeTestCases: true,
718
- resolvePath: resolvePathForFiles
719
- });
720
- }
721
- /**
722
706
  * Create TeleReporterReceiver for listing tests
723
707
  * Uses standard absolute path resolution
724
708
  */
@@ -773,11 +757,16 @@ var PlaywrightTestServer = class {
773
757
  }
774
758
  async listFiles() {
775
759
  const connection = await this._ensureConnection();
776
- const result = { projects: [] };
777
- const { report } = await connection.listFiles({});
760
+ const result = {
761
+ rootDir: this._config.workspaceFolder,
762
+ projects: []
763
+ };
764
+ const { report, status } = await connection.listFiles({});
778
765
  const workspaceFolder = this._config.workspaceFolder;
779
- processReport(createFileListReceiver({
780
- onConfigure: (_config) => {},
766
+ processReport(createTestListReceiver({
767
+ onConfigure: (_config) => {
768
+ result.rootDir = _config.rootDir;
769
+ },
781
770
  onBegin(rootSuite) {
782
771
  for (const projectSuite of rootSuite.suites) {
783
772
  const project = projectSuite.project();
@@ -789,24 +778,31 @@ var PlaywrightTestServer = class {
789
778
  use: project.use || {},
790
779
  files
791
780
  });
792
- 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, "/"));
793
782
  }
794
783
  },
795
784
  onError(error) {
796
785
  result.error = error;
797
786
  }
798
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}`);
799
790
  return result;
800
791
  }
801
792
  /**
802
793
  * Get raw test report without processing through TeleReporterReceiver
803
794
  * This preserves the complete test.describe hierarchy
804
795
  */
805
- async getRawTestReport(locations, token) {
796
+ async getRawTestReport(locations, token, projects) {
806
797
  if (token?.isCancellationRequested) return [];
807
- const connection = await this._ensureConnection();
808
- const escapedLocations = locations.map(escapeRegex);
809
- 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
+ }
810
806
  return report;
811
807
  }
812
808
  async listTests(locations, reporter, token) {
@@ -905,203 +901,381 @@ var PlaywrightTestServer = class {
905
901
  }
906
902
  };
907
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
+ }
1008
+ //#endregion
908
1009
  //#region src/api.ts
909
- async function listTestFile(workspaceFolder = process.cwd(), configFile = "playwright.config.ts", outputFile) {
910
- 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([
911
1033
  workspaceFolder,
912
- 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: []
913
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`;
914
1090
  try {
915
- const output = { projects: (await testServer.listFiles()).projects.map((project) => ({
916
- ...project,
917
- files: project.files.map((filePath) => filePath.replace(/\\/g, "/"))
918
- })) };
919
- const jsonString = JSON.stringify(output, null, 2);
920
- if (outputFile) {
921
- await fs.writeFile(outputFile, jsonString, "utf-8");
922
- console.log(`Results saved to: ${outputFile}`);
923
- } else console.log(jsonString);
924
- testServer.close();
925
- } catch (error) {
926
- console.error("Error:", error);
927
- testServer.close();
1091
+ await fs.writeFile(temporary, value);
1092
+ await fs.rename(temporary, file);
1093
+ } finally {
1094
+ await fs.unlink(temporary).catch(() => {});
928
1095
  }
929
1096
  }
930
- async function listTestCase(workspaceFolder = process.cwd(), configFile = "playwright.config.ts", relativePaths, outputFile) {
931
- const testServer = new PlaywrightTestServer({
932
- workspaceFolder,
933
- 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)
934
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);
935
1187
  try {
936
- const token = new CancellationToken();
937
- const expandedPaths = [];
938
- for (const relativePath of relativePaths) {
939
- const normalizedInputPath = relativePath.replace(/\\/g, "/").replace(/\/$/, "");
940
- const absolutePath = path.join(workspaceFolder, relativePath);
941
- try {
942
- if ((await fs.stat(absolutePath)).isDirectory()) {
943
- const files = await testServer.listFiles();
944
- for (const project of files.projects) {
945
- const testDirRelative = project.testDir || "";
946
- for (const file of project.files) {
947
- const fullPath = testDirRelative ? path.join(testDirRelative, file).replace(/\\/g, "/") : file.replace(/\\/g, "/");
948
- if (fullPath.startsWith(normalizedInputPath + "/") || fullPath === normalizedInputPath) expandedPaths.push(fullPath);
949
- }
950
- }
951
- } else expandedPaths.push(normalizedInputPath);
952
- } catch {
953
- 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);
954
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);
955
1202
  }
956
- if (expandedPaths.length === 0) {
957
- testServer.close();
958
- return;
959
- }
960
- const report = await testServer.getRawTestReport(expandedPaths, token);
961
- function parseEntry(entry, testDir) {
962
- if (entry.testId) {
963
- const filePath = testDir ? path.join(testDir, entry.location.file).replace(/\\/g, "/") : entry.location.file.replace(/\\/g, "/");
964
- return {
965
- type: "test",
966
- title: entry.title,
967
- testId: entry.testId,
968
- location: {
969
- file: filePath,
970
- line: entry.location.line,
971
- column: entry.location.column
972
- },
973
- tags: entry.tags || [],
974
- annotations: entry.annotations || [],
975
- expectedStatus: "passed",
976
- timeout: 0
977
- };
978
- } else {
979
- const filePath = entry.location && testDir ? path.join(testDir, entry.location.file).replace(/\\/g, "/") : entry.location?.file.replace(/\\/g, "/");
980
- const node = {
981
- type: "suite",
982
- title: entry.title,
983
- file: filePath,
984
- children: []
985
- };
986
- if (entry.entries && Array.isArray(entry.entries)) for (const childEntry of entry.entries) node.children.push(parseEntry(childEntry, testDir));
987
- 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);
988
1212
  }
1213
+ updatedByProject.set(project.title, files);
989
1214
  }
990
- const rootNode = {
991
- type: "suite",
992
- title: "Root",
993
- children: []
994
- };
995
- for (const message of report) if (message.method === "onProject") {
996
- const project = message.params.project;
997
- const projectNode = {
998
- type: "suite",
999
- title: project.name,
1000
- children: []
1001
- };
1002
- let testDir = "";
1003
- if (project.testDir) testDir = path.isAbsolute(project.testDir) ? path.relative(workspaceFolder, project.testDir).replace(/\\/g, "/") : project.testDir.replace(/\\/g, "/");
1004
- if (project.suites && Array.isArray(project.suites)) for (const suite of project.suites) {
1005
- const suiteNode = parseEntry(suite, testDir);
1006
- 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));
1007
1221
  }
1008
- rootNode.children.push(projectNode);
1222
+ updatedByProject.set(project.name, files);
1009
1223
  }
1010
- const jsonString = JSON.stringify(rootNode, null, 2);
1011
- if (outputFile) {
1012
- await fs.writeFile(outputFile, jsonString, "utf-8");
1013
- console.log(`Results saved to: ${outputFile}`);
1014
- } else console.log(jsonString);
1015
- testServer.close();
1016
- } catch (error) {
1017
- console.error("Error:", error);
1018
- 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();
1019
1237
  }
1020
1238
  }
1021
- async function treeStructure(workspaceFolder = process.cwd(), configFile = "playwright.config.ts", cancellationToken, outputFile) {
1022
- const testServer = new PlaywrightTestServer({
1023
- workspaceFolder,
1024
- configFile: path.join(workspaceFolder, configFile)
1025
- });
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);
1026
1248
  try {
1027
- const token = cancellationToken || new CancellationToken();
1028
- const files = await testServer.listFiles();
1029
- if (files.projects.length === 0) {
1030
- testServer.close();
1031
- return;
1032
- }
1033
- const allRelativePaths = [];
1034
- for (const project of files.projects) if (project.files.length > 0) {
1035
- const testDirRelative = project.testDir || "";
1036
- const relativePaths = project.files.map((file) => {
1037
- return testDirRelative ? path.join(testDirRelative, file).replace(/\\/g, "/") : file.replace(/\\/g, "/");
1038
- });
1039
- allRelativePaths.push(...relativePaths);
1040
- }
1041
- if (allRelativePaths.length === 0) {
1042
- testServer.close();
1043
- return;
1044
- }
1045
- const report = await testServer.getRawTestReport(allRelativePaths, token);
1046
- function parseEntry(entry, testDir) {
1047
- if (entry.testId) {
1048
- const filePath = entry.location.file.replace(/\\/g, "/");
1049
- return {
1050
- type: "test",
1051
- title: entry.title,
1052
- testId: entry.testId,
1053
- location: {
1054
- file: filePath,
1055
- line: entry.location.line,
1056
- column: entry.location.column
1057
- },
1058
- tags: entry.tags || [],
1059
- annotations: entry.annotations || [],
1060
- expectedStatus: "passed",
1061
- timeout: 0
1062
- };
1063
- } else {
1064
- const filePath = entry.location?.file.replace(/\\/g, "/");
1065
- const node = {
1066
- type: "suite",
1067
- title: entry.title,
1068
- file: filePath,
1069
- children: []
1070
- };
1071
- if (entry.entries && Array.isArray(entry.entries)) for (const childEntry of entry.entries) node.children.push(parseEntry(childEntry, testDir));
1072
- return node;
1073
- }
1074
- }
1075
- const rootNode = {
1076
- type: "suite",
1077
- title: "Root",
1078
- children: []
1079
- };
1080
- for (const message of report) if (message.method === "onProject") {
1081
- const project = message.params.project;
1082
- const projectNode = {
1083
- type: "suite",
1084
- title: project.name,
1085
- children: []
1086
- };
1087
- let testDir = "";
1088
- if (project.testDir) testDir = path.isAbsolute(project.testDir) ? path.relative(workspaceFolder, project.testDir).replace(/\\/g, "/") : project.testDir.replace(/\\/g, "/");
1089
- if (project.suites && Array.isArray(project.suites)) for (const suite of project.suites) {
1090
- const suiteNode = parseEntry(suite, testDir);
1091
- projectNode.children.push(suiteNode);
1092
- }
1093
- 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);
1094
1270
  }
1095
- const jsonString = JSON.stringify(rootNode, null, 2);
1096
- if (outputFile) {
1097
- await fs.writeFile(outputFile, jsonString, "utf-8");
1098
- console.log(`Results saved to: ${outputFile}`);
1099
- } else console.log(jsonString);
1100
- testServer.close();
1101
- } catch (error) {
1102
- console.error("Error:", error);
1103
- testServer.close();
1271
+ if (!expandedPaths.size) return;
1272
+ await outputJson(consumeTestReport(await server.getRawTestReport([...expandedPaths])), outputFile);
1273
+ } finally {
1274
+ server.close();
1104
1275
  }
1105
1276
  }
1277
+ async function treeStructure(workspaceFolder = process.cwd(), configFile = "playwright.config.ts", cancellationToken, outputFile) {
1278
+ await outputJson(await collectFullTree(workspaceFolder, configFile, cancellationToken), outputFile);
1279
+ }
1106
1280
  //#endregion
1107
- 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,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { i as CancellationToken, n as listTestFile, r as treeStructure, t as listTestCase } from "./api-Bbd6qGsY.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";
@@ -40,8 +40,8 @@ program.command("list-tests").description("List all test cases in specified file
40
40
  process.exit(1);
41
41
  }
42
42
  });
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").action(async (options) => {
44
- 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();
45
45
  try {
46
46
  const workspaceFolder = path.resolve(options.workspace);
47
47
  const token = new CancellationToken();
@@ -51,8 +51,15 @@ program.command("tree").description("Display test structure as a tree").option("
51
51
  process.exit(0);
52
52
  });
53
53
  const outputFile = options.output ? path.resolve(options.output) : void 0;
54
- await treeStructure(workspaceFolder, options.config, token, outputFile);
55
- 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
+ }
56
63
  } catch (error) {
57
64
  spinner.fail(chalk.red("Failed to build test tree"));
58
65
  console.error(chalk.red("Error:"), error instanceof Error ? error.message : String(error));
@@ -89,6 +96,9 @@ ${chalk.bold("Examples:")}
89
96
  ${chalk.cyan("# Save tree structure to JSON file")}
90
97
  $ pw-test-explorer tree -o tree.json
91
98
 
99
+ ${chalk.cyan("# Incrementally update a saved tree from Git changes")}
100
+ $ pw-test-explorer tree --incremental -o tree.json
101
+
92
102
  ${chalk.cyan("# Display tree for specific workspace")}
93
103
  $ pw-test-explorer tree -w /path/to/project
94
104
  `);
package/dist/index.d.mts CHANGED
@@ -34,8 +34,19 @@ declare class CancellationToken {
34
34
  }
35
35
  //#endregion
36
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>;
37
48
  declare function listTestFile(workspaceFolder?: string, configFile?: string, outputFile?: string): Promise<void>;
38
49
  declare function listTestCase(workspaceFolder: string | undefined, configFile: string | undefined, relativePaths: string[], outputFile?: string): Promise<void>;
39
50
  declare function treeStructure(workspaceFolder?: string, configFile?: string, cancellationToken?: CancellationToken, outputFile?: string): Promise<void>;
40
51
  //#endregion
41
- export { listTestCase, listTestFile, treeStructure };
52
+ export { type IncrementalTreeOptions, listTestCase, listTestFile, treeStructure, updateTreeStructure };
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as listTestFile, r as treeStructure, t as listTestCase } from "./api-Bbd6qGsY.mjs";
2
- 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,6 +1,6 @@
1
1
  {
2
2
  "name": "playwright-test-explorer",
3
- "version": "0.0.12",
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",