@toolstackhq/create-qa-patterns 1.0.13 → 1.0.14

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
@@ -40,6 +40,24 @@ Generate without post-create prompts, which is useful for CI or scripted setup:
40
40
  create-qa-patterns playwright-template my-project --yes --no-install --no-setup --no-test
41
41
  ```
42
42
 
43
+ ## Upgrade a generated project
44
+
45
+ Generated projects now include a `.qa-patterns.json` metadata file. It tracks the last applied managed template baseline so the CLI can update infrastructure files conservatively later.
46
+
47
+ Check for safe updates:
48
+
49
+ ```bash
50
+ create-qa-patterns upgrade check my-project
51
+ ```
52
+
53
+ Apply only safe managed-file updates:
54
+
55
+ ```bash
56
+ create-qa-patterns upgrade apply --safe my-project
57
+ ```
58
+
59
+ The upgrade flow intentionally avoids overwriting user-owned test and page code. It only manages framework infrastructure such as config, scripts, workflows, and package metadata when those files are still unchanged from the generated baseline.
60
+
43
61
  ## Supported templates
44
62
 
45
63
  - `playwright-template`
package/index.js CHANGED
@@ -3,9 +3,12 @@
3
3
  const fs = require("node:fs");
4
4
  const path = require("node:path");
5
5
  const readline = require("node:readline");
6
+ const crypto = require("node:crypto");
6
7
  const { spawn, spawnSync } = require("node:child_process");
7
8
 
8
9
  const DEFAULT_TEMPLATE = "playwright-template";
10
+ const CLI_PACKAGE = require("./package.json");
11
+ const METADATA_FILENAME = ".qa-patterns.json";
9
12
  const MIN_NODE_VERSION = {
10
13
  major: 18,
11
14
  minor: 18,
@@ -42,6 +45,30 @@ test-results/
42
45
  playwright-report/
43
46
  `;
44
47
 
48
+ const MANAGED_FILE_PATTERNS = {
49
+ common: [
50
+ ".env.example",
51
+ ".gitignore",
52
+ "package.json",
53
+ "package-lock.json",
54
+ "tsconfig.json",
55
+ "eslint.config.mjs",
56
+ "allurerc.mjs",
57
+ "config/**",
58
+ "scripts/**",
59
+ ".github/**"
60
+ ],
61
+ "playwright-template": [
62
+ "playwright.config.ts",
63
+ "docker/**",
64
+ "lint/**",
65
+ "reporters/**",
66
+ "utils/logger.ts",
67
+ "utils/test-step.ts"
68
+ ],
69
+ "cypress-template": ["cypress.config.ts"]
70
+ };
71
+
45
72
  const TEMPLATES = [
46
73
  {
47
74
  id: DEFAULT_TEMPLATE,
@@ -109,6 +136,222 @@ const colors = {
109
136
  }
110
137
  };
111
138
 
139
+ function sha256(content) {
140
+ return crypto.createHash("sha256").update(content).digest("hex");
141
+ }
142
+
143
+ function normalizePath(value) {
144
+ return value.split(path.sep).join("/");
145
+ }
146
+
147
+ function getTemplateDirectory(templateId) {
148
+ return path.resolve(__dirname, "templates", templateId);
149
+ }
150
+
151
+ function pathMatchesPattern(relativePath, pattern) {
152
+ if (pattern.endsWith("/**")) {
153
+ const prefix = pattern.slice(0, -3);
154
+ return relativePath === prefix || relativePath.startsWith(`${prefix}/`);
155
+ }
156
+
157
+ return relativePath === pattern;
158
+ }
159
+
160
+ function isManagedFile(template, relativePath) {
161
+ const patterns = [...MANAGED_FILE_PATTERNS.common, ...(MANAGED_FILE_PATTERNS[template.id] || [])];
162
+ return patterns.some((pattern) => pathMatchesPattern(relativePath, pattern));
163
+ }
164
+
165
+ function collectRelativeFiles(rootDirectory) {
166
+ const results = [];
167
+
168
+ function visit(currentDirectory) {
169
+ const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
170
+
171
+ for (const entry of entries) {
172
+ const absolutePath = path.join(currentDirectory, entry.name);
173
+ const relativePath = normalizePath(path.relative(rootDirectory, absolutePath));
174
+
175
+ if (entry.isDirectory()) {
176
+ visit(absolutePath);
177
+ } else {
178
+ results.push(relativePath);
179
+ }
180
+ }
181
+ }
182
+
183
+ visit(rootDirectory);
184
+
185
+ return results.sort();
186
+ }
187
+
188
+ function transformTemplateFile(relativePath, content, targetDirectory, template) {
189
+ const packageName = toPackageName(targetDirectory, template);
190
+
191
+ if (relativePath === "package.json") {
192
+ const pkg = JSON.parse(content);
193
+ return `${JSON.stringify({ ...pkg, name: packageName }, null, 2)}\n`;
194
+ }
195
+
196
+ if (relativePath === "package-lock.json") {
197
+ const lock = JSON.parse(content);
198
+ return `${JSON.stringify(
199
+ {
200
+ ...lock,
201
+ name: packageName,
202
+ packages: lock.packages
203
+ ? {
204
+ ...lock.packages,
205
+ "": {
206
+ ...lock.packages[""],
207
+ name: packageName
208
+ }
209
+ }
210
+ : lock.packages
211
+ },
212
+ null,
213
+ 2
214
+ )}\n`;
215
+ }
216
+
217
+ return content;
218
+ }
219
+
220
+ function renderTemplateFile(template, relativePath, targetDirectory) {
221
+ if (relativePath === ".gitignore") {
222
+ const gitignorePath = path.join(getTemplateDirectory(template.id), ".gitignore");
223
+ const gitignoreContent = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : DEFAULT_GITIGNORE;
224
+ return transformTemplateFile(relativePath, gitignoreContent, targetDirectory, template);
225
+ }
226
+
227
+ const sourcePath = path.join(getTemplateDirectory(template.id), relativePath);
228
+ const content = fs.readFileSync(sourcePath, "utf8");
229
+ return transformTemplateFile(relativePath, content, targetDirectory, template);
230
+ }
231
+
232
+ function getManagedRelativePaths(template) {
233
+ const templateDirectory = getTemplateDirectory(template.id);
234
+ const templateFiles = collectRelativeFiles(templateDirectory).filter((relativePath) => isManagedFile(template, relativePath));
235
+ const managedFiles = new Set(templateFiles);
236
+ managedFiles.add(".gitignore");
237
+ managedFiles.delete(METADATA_FILENAME);
238
+ return [...managedFiles].sort();
239
+ }
240
+
241
+ function getMetadataPath(targetDirectory) {
242
+ return path.join(targetDirectory, METADATA_FILENAME);
243
+ }
244
+
245
+ function buildProjectMetadata(template, targetDirectory) {
246
+ const managedFiles = {};
247
+
248
+ for (const relativePath of getManagedRelativePaths(template)) {
249
+ const absolutePath = path.join(targetDirectory, relativePath);
250
+ if (!fs.existsSync(absolutePath)) {
251
+ continue;
252
+ }
253
+
254
+ managedFiles[relativePath] = {
255
+ baselineHash: sha256(fs.readFileSync(absolutePath, "utf8"))
256
+ };
257
+ }
258
+
259
+ return {
260
+ schemaVersion: 1,
261
+ template: template.id,
262
+ templateVersion: CLI_PACKAGE.version,
263
+ packageName: toPackageName(targetDirectory, template),
264
+ generatedAt: new Date().toISOString(),
265
+ managedFiles
266
+ };
267
+ }
268
+
269
+ function writeProjectMetadata(template, targetDirectory, existingMetadata) {
270
+ const nextMetadata = buildProjectMetadata(template, targetDirectory);
271
+
272
+ if (existingMetadata) {
273
+ nextMetadata.generatedAt = existingMetadata.generatedAt || nextMetadata.generatedAt;
274
+ nextMetadata.templateVersion = existingMetadata.templateVersion || nextMetadata.templateVersion;
275
+ }
276
+
277
+ fs.writeFileSync(getMetadataPath(targetDirectory), `${JSON.stringify(nextMetadata, null, 2)}\n`, "utf8");
278
+ return nextMetadata;
279
+ }
280
+
281
+ function readProjectMetadata(targetDirectory) {
282
+ const metadataPath = getMetadataPath(targetDirectory);
283
+
284
+ if (!fs.existsSync(metadataPath)) {
285
+ throw new Error(`No ${METADATA_FILENAME} file found in ${targetDirectory}.`);
286
+ }
287
+
288
+ return JSON.parse(fs.readFileSync(metadataPath, "utf8"));
289
+ }
290
+
291
+ function detectTemplateFromProject(targetDirectory) {
292
+ const metadataPath = getMetadataPath(targetDirectory);
293
+ if (fs.existsSync(metadataPath)) {
294
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
295
+ return metadata.template;
296
+ }
297
+
298
+ if (fs.existsSync(path.join(targetDirectory, "playwright.config.ts"))) {
299
+ return "playwright-template";
300
+ }
301
+
302
+ if (fs.existsSync(path.join(targetDirectory, "cypress.config.ts"))) {
303
+ return "cypress-template";
304
+ }
305
+
306
+ throw new Error(`Could not detect the template used for ${targetDirectory}.`);
307
+ }
308
+
309
+ function analyzeUpgrade(template, targetDirectory, metadata) {
310
+ const managedPaths = getManagedRelativePaths(template);
311
+ const results = [];
312
+
313
+ for (const relativePath of managedPaths) {
314
+ const absolutePath = path.join(targetDirectory, relativePath);
315
+ const latestContent = renderTemplateFile(template, relativePath, targetDirectory);
316
+ const latestHash = sha256(latestContent);
317
+ const baselineHash = metadata.managedFiles?.[relativePath]?.baselineHash || null;
318
+ const currentExists = fs.existsSync(absolutePath);
319
+ const currentHash = currentExists ? sha256(fs.readFileSync(absolutePath, "utf8")) : null;
320
+
321
+ let status = "up-to-date";
322
+
323
+ if (!baselineHash) {
324
+ if (!currentExists) {
325
+ status = "new-file";
326
+ } else if (currentHash === latestHash) {
327
+ status = "new-file";
328
+ } else {
329
+ status = "conflict";
330
+ }
331
+ } else if (!currentExists) {
332
+ status = "conflict";
333
+ } else if (currentHash === latestHash) {
334
+ status = "up-to-date";
335
+ } else if (currentHash === baselineHash) {
336
+ status = "safe-update";
337
+ } else {
338
+ status = "conflict";
339
+ }
340
+
341
+ results.push({
342
+ relativePath,
343
+ status,
344
+ latestContent,
345
+ latestHash,
346
+ currentHash,
347
+ baselineHash,
348
+ currentExists
349
+ });
350
+ }
351
+
352
+ return results;
353
+ }
354
+
112
355
  function printHelp() {
113
356
  const supportedTemplates = TEMPLATES.map((template) => ` ${template.id}${template.aliases.length > 0 ? ` (${template.aliases.join(", ")})` : ""}`).join("\n");
114
357
 
@@ -119,6 +362,8 @@ Usage:
119
362
  create-qa-patterns <target-directory>
120
363
  create-qa-patterns <template> [target-directory]
121
364
  create-qa-patterns --template <template> [target-directory]
365
+ create-qa-patterns upgrade check [target-directory]
366
+ create-qa-patterns upgrade apply --safe [target-directory]
122
367
 
123
368
  Options:
124
369
  --yes Accept all post-generate prompts
@@ -126,6 +371,7 @@ Options:
126
371
  --no-setup Skip template-specific setup such as Playwright browser install
127
372
  --no-test Skip npm test
128
373
  --template Explicitly choose a template without using positional arguments
374
+ --safe Required with upgrade apply; only updates unchanged managed files
129
375
 
130
376
  Interactive mode:
131
377
  When run without an explicit template, the CLI shows an interactive template picker.
@@ -141,6 +387,7 @@ function parseCliOptions(args) {
141
387
  noInstall: false,
142
388
  noSetup: false,
143
389
  noTest: false,
390
+ safe: false,
144
391
  templateName: null,
145
392
  positionalArgs: []
146
393
  };
@@ -161,6 +408,9 @@ function parseCliOptions(args) {
161
408
  case "--no-test":
162
409
  options.noTest = true;
163
410
  break;
411
+ case "--safe":
412
+ options.safe = true;
413
+ break;
164
414
  case "--template": {
165
415
  const templateValue = args[index + 1];
166
416
  if (!templateValue) {
@@ -529,32 +779,24 @@ function updateJsonFile(filePath, update) {
529
779
  }
530
780
 
531
781
  function customizeProject(targetDirectory, template) {
532
- const packageName = toPackageName(targetDirectory, template);
533
782
  const packageJsonPath = path.join(targetDirectory, "package.json");
534
783
  const packageLockPath = path.join(targetDirectory, "package-lock.json");
535
784
  const gitignorePath = path.join(targetDirectory, ".gitignore");
536
785
 
537
786
  if (fs.existsSync(packageJsonPath)) {
538
- updateJsonFile(packageJsonPath, (pkg) => ({
539
- ...pkg,
540
- name: packageName
541
- }));
787
+ fs.writeFileSync(
788
+ packageJsonPath,
789
+ transformTemplateFile("package.json", fs.readFileSync(packageJsonPath, "utf8"), targetDirectory, template),
790
+ "utf8"
791
+ );
542
792
  }
543
793
 
544
794
  if (fs.existsSync(packageLockPath)) {
545
- updateJsonFile(packageLockPath, (lock) => ({
546
- ...lock,
547
- name: packageName,
548
- packages: lock.packages
549
- ? {
550
- ...lock.packages,
551
- "": {
552
- ...lock.packages[""],
553
- name: packageName
554
- }
555
- }
556
- : lock.packages
557
- }));
795
+ fs.writeFileSync(
796
+ packageLockPath,
797
+ transformTemplateFile("package-lock.json", fs.readFileSync(packageLockPath, "utf8"), targetDirectory, template),
798
+ "utf8"
799
+ );
558
800
  }
559
801
 
560
802
  if (!fs.existsSync(gitignorePath)) {
@@ -734,6 +976,85 @@ function formatStatus(status) {
734
976
  }
735
977
  }
736
978
 
979
+ function formatUpgradeStatus(status) {
980
+ switch (status) {
981
+ case "safe-update":
982
+ return colors.green("safe update available");
983
+ case "new-file":
984
+ return colors.green("new managed file available");
985
+ case "conflict":
986
+ return colors.yellow("manual review required");
987
+ default:
988
+ return colors.dim("up to date");
989
+ }
990
+ }
991
+
992
+ function printUpgradeReport(targetDirectory, metadata, results) {
993
+ const safeCount = results.filter((entry) => entry.status === "safe-update").length;
994
+ const newCount = results.filter((entry) => entry.status === "new-file").length;
995
+ const conflictCount = results.filter((entry) => entry.status === "conflict").length;
996
+
997
+ process.stdout.write(`\n${colors.bold("Upgrade check")}\n`);
998
+ process.stdout.write(` Target: ${targetDirectory}\n`);
999
+ process.stdout.write(` Template: ${metadata.template}\n`);
1000
+ process.stdout.write(` Current baseline version: ${metadata.templateVersion}\n`);
1001
+ process.stdout.write(` CLI template version: ${CLI_PACKAGE.version}\n`);
1002
+ process.stdout.write(` Safe updates: ${safeCount}\n`);
1003
+ process.stdout.write(` New managed files: ${newCount}\n`);
1004
+ process.stdout.write(` Conflicts: ${conflictCount}\n\n`);
1005
+
1006
+ for (const entry of results) {
1007
+ if (entry.status === "up-to-date") {
1008
+ continue;
1009
+ }
1010
+
1011
+ process.stdout.write(` ${entry.relativePath}: ${formatUpgradeStatus(entry.status)}\n`);
1012
+ }
1013
+
1014
+ if (safeCount === 0 && newCount === 0 && conflictCount === 0) {
1015
+ process.stdout.write(`${colors.green("Everything already matches the current managed template files.")}\n`);
1016
+ }
1017
+
1018
+ process.stdout.write("\n");
1019
+ }
1020
+
1021
+ function applySafeUpdates(targetDirectory, metadata, results) {
1022
+ const nextMetadata = {
1023
+ ...metadata,
1024
+ managedFiles: {
1025
+ ...metadata.managedFiles
1026
+ }
1027
+ };
1028
+
1029
+ let appliedCount = 0;
1030
+
1031
+ for (const entry of results) {
1032
+ if (!["safe-update", "new-file"].includes(entry.status)) {
1033
+ continue;
1034
+ }
1035
+
1036
+ const absolutePath = path.join(targetDirectory, entry.relativePath);
1037
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
1038
+ fs.writeFileSync(absolutePath, entry.latestContent, "utf8");
1039
+ nextMetadata.managedFiles[entry.relativePath] = {
1040
+ baselineHash: entry.latestHash
1041
+ };
1042
+ appliedCount += 1;
1043
+ }
1044
+
1045
+ const remainingConflicts = results.filter((entry) => entry.status === "conflict").length;
1046
+ if (remainingConflicts === 0) {
1047
+ nextMetadata.templateVersion = CLI_PACKAGE.version;
1048
+ }
1049
+
1050
+ fs.writeFileSync(getMetadataPath(targetDirectory), `${JSON.stringify(nextMetadata, null, 2)}\n`, "utf8");
1051
+
1052
+ process.stdout.write(`\n${colors.bold("Upgrade apply")}\n`);
1053
+ process.stdout.write(` Applied safe updates: ${appliedCount}\n`);
1054
+ process.stdout.write(` Remaining conflicts: ${remainingConflicts}\n`);
1055
+ process.stdout.write("\n");
1056
+ }
1057
+
737
1058
  function printSummary(summary) {
738
1059
  process.stdout.write(`\n${colors.bold("Summary")}\n`);
739
1060
  process.stdout.write(` Template: ${summary.template.id}\n`);
@@ -824,11 +1145,56 @@ async function runPostGenerateActions(template, targetDirectory, summary) {
824
1145
  }
825
1146
  }
826
1147
 
1148
+ function resolveUpgradeTarget(args) {
1149
+ if (args.length > 1) {
1150
+ throw new Error("Too many arguments for upgrade. Use `create-qa-patterns upgrade check [target-directory]`.");
1151
+ }
1152
+
1153
+ return path.resolve(process.cwd(), args[0] || ".");
1154
+ }
1155
+
1156
+ function runUpgradeCommand(rawArgs) {
1157
+ const [subcommand = "check", ...rest] = rawArgs;
1158
+ const options = parseCliOptions(rest);
1159
+ const targetDirectory = resolveUpgradeTarget(options.positionalArgs);
1160
+ const metadata = readProjectMetadata(targetDirectory);
1161
+ const templateId = metadata.template || detectTemplateFromProject(targetDirectory);
1162
+ const template = getTemplate(templateId);
1163
+
1164
+ if (!template) {
1165
+ throw new Error(`Unsupported template "${templateId}".`);
1166
+ }
1167
+
1168
+ const results = analyzeUpgrade(template, targetDirectory, metadata);
1169
+
1170
+ if (subcommand === "check" || subcommand === "report") {
1171
+ printUpgradeReport(targetDirectory, metadata, results);
1172
+ return;
1173
+ }
1174
+
1175
+ if (subcommand === "apply") {
1176
+ if (!options.safe) {
1177
+ throw new Error("Upgrade apply requires --safe. Only safe managed-file updates are supported.");
1178
+ }
1179
+
1180
+ printUpgradeReport(targetDirectory, metadata, results);
1181
+ applySafeUpdates(targetDirectory, metadata, results);
1182
+ return;
1183
+ }
1184
+
1185
+ throw new Error(`Unsupported upgrade command "${subcommand}". Use check, report, or apply --safe.`);
1186
+ }
1187
+
827
1188
  async function main() {
828
1189
  const rawArgs = process.argv.slice(2);
829
1190
 
830
1191
  assertSupportedNodeVersion();
831
1192
 
1193
+ if (rawArgs[0] === "upgrade") {
1194
+ runUpgradeCommand(rawArgs.slice(1));
1195
+ return;
1196
+ }
1197
+
832
1198
  if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
833
1199
  printHelp();
834
1200
  return;
@@ -850,9 +1216,11 @@ async function main() {
850
1216
  summary.options = options;
851
1217
  printPrerequisiteWarnings(prerequisites);
852
1218
  await scaffoldProject(template, targetDirectory, prerequisites);
1219
+ writeProjectMetadata(template, targetDirectory);
853
1220
  summary.gitInit = prerequisites.git ? "completed" : "unavailable";
854
1221
  printSuccess(template, targetDirectory, generatedInCurrentDirectory);
855
1222
  await runPostGenerateActions(template, targetDirectory, summary);
1223
+ writeProjectMetadata(template, targetDirectory, readProjectMetadata(targetDirectory));
856
1224
  printSummary(summary);
857
1225
  printNextSteps(summary);
858
1226
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toolstackhq/create-qa-patterns",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "CLI for generating QA framework templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,6 +13,7 @@ This is a Cypress + TypeScript automation framework template for a small determi
13
13
  - [Reports and artifacts](#reports-and-artifacts)
14
14
  - [Add a new test](#add-a-new-test)
15
15
  - [Extend the framework](#extend-the-framework)
16
+ - [Template upgrades](#template-upgrades)
16
17
  - [CI](#ci)
17
18
 
18
19
  ## Feature set
@@ -197,6 +198,24 @@ Recommended rules:
197
198
  - use Cypress commands for workflows, not giant helper classes
198
199
  - keep the data layer generic until the project really needs domain-specific factories
199
200
 
201
+ ## Template upgrades
202
+
203
+ This project includes a `.qa-patterns.json` metadata file so future CLI versions can compare the current project against the managed template baseline.
204
+
205
+ Check for available safe updates:
206
+
207
+ ```bash
208
+ npx -y @toolstackhq/create-qa-patterns upgrade check .
209
+ ```
210
+
211
+ Apply only safe managed-file updates:
212
+
213
+ ```bash
214
+ npx -y @toolstackhq/create-qa-patterns upgrade apply --safe .
215
+ ```
216
+
217
+ The upgrade flow is conservative. It updates framework infrastructure such as config, scripts, workflows, and package metadata when those files are still unchanged from the generated baseline. If you changed a managed file yourself, the CLI reports a conflict instead of overwriting it.
218
+
200
219
  ## CI
201
220
 
202
221
  The included workflow lives at:
@@ -13,6 +13,7 @@ This is a Playwright + TypeScript automation framework template for UI and API t
13
13
  - [Reports and artifacts](#reports-and-artifacts)
14
14
  - [Add a new test](#add-a-new-test)
15
15
  - [Extend the framework](#extend-the-framework)
16
+ - [Template upgrades](#template-upgrades)
16
17
  - [CI and Docker](#ci-and-docker)
17
18
 
18
19
  ## Feature set
@@ -228,6 +229,24 @@ Recommended rules:
228
229
  - prefer semantic selectors such as `getByRole`, `getByLabel`, and `data-testid`
229
230
  - keep the data layer generic until the project really needs domain-specific factories
230
231
 
232
+ ## Template upgrades
233
+
234
+ This project includes a `.qa-patterns.json` metadata file so future CLI versions can compare the current project against the managed template baseline.
235
+
236
+ Check for available safe updates:
237
+
238
+ ```bash
239
+ npx -y @toolstackhq/create-qa-patterns upgrade check .
240
+ ```
241
+
242
+ Apply only safe managed-file updates:
243
+
244
+ ```bash
245
+ npx -y @toolstackhq/create-qa-patterns upgrade apply --safe .
246
+ ```
247
+
248
+ The upgrade flow is conservative. It updates framework infrastructure such as config, scripts, workflows, and package metadata when those files are still unchanged from the generated baseline. If you changed a managed file yourself, the CLI reports a conflict instead of overwriting it.
249
+
231
250
  ## CI and Docker
232
251
 
233
252
  The CI entrypoint is: