@toolstackhq/create-qa-patterns 1.0.4 → 1.0.6

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/index.js CHANGED
@@ -11,6 +11,7 @@ const MIN_NODE_VERSION = {
11
11
  minor: 18,
12
12
  patch: 0
13
13
  };
14
+ const COLOR_ENABLED = Boolean(process.stdout.isTTY) && !("NO_COLOR" in process.env);
14
15
  const DEFAULT_GITIGNORE = `node_modules/
15
16
 
16
17
  .env
@@ -40,8 +41,37 @@ const TEMPLATE_ALIASES = new Map(
40
41
  ])
41
42
  );
42
43
 
44
+ function style(text, ...codes) {
45
+ if (!COLOR_ENABLED) {
46
+ return text;
47
+ }
48
+
49
+ return `\u001b[${codes.join(";")}m${text}\u001b[0m`;
50
+ }
51
+
52
+ const colors = {
53
+ bold(text) {
54
+ return style(text, 1);
55
+ },
56
+ dim(text) {
57
+ return style(text, 2);
58
+ },
59
+ cyan(text) {
60
+ return style(text, 36);
61
+ },
62
+ green(text) {
63
+ return style(text, 32);
64
+ },
65
+ yellow(text) {
66
+ return style(text, 33);
67
+ },
68
+ red(text) {
69
+ return style(text, 31);
70
+ }
71
+ };
72
+
43
73
  function printHelp() {
44
- process.stdout.write(`create-qa-patterns
74
+ process.stdout.write(`${colors.bold("create-qa-patterns")}
45
75
 
46
76
  Usage:
47
77
  create-qa-patterns
@@ -121,15 +151,15 @@ function collectPrerequisites() {
121
151
 
122
152
  function printPrerequisiteWarnings(prerequisites) {
123
153
  if (!prerequisites.npm) {
124
- process.stdout.write("Warning: npm was not found. Automated install and test steps will be unavailable.\n");
154
+ process.stdout.write(`${colors.yellow("Warning:")} npm was not found. Automated install and test steps will be unavailable.\n`);
125
155
  }
126
156
 
127
157
  if (!prerequisites.npx) {
128
- process.stdout.write("Warning: npx was not found. Playwright browser installation will be unavailable.\n");
158
+ process.stdout.write(`${colors.yellow("Warning:")} npx was not found. Playwright browser installation will be unavailable.\n`);
129
159
  }
130
160
 
131
161
  if (!prerequisites.docker) {
132
- process.stdout.write("Warning: docker was not found. Docker-based template flows will not run until Docker is installed.\n");
162
+ process.stdout.write(`${colors.yellow("Warning:")} docker was not found. Docker-based template flows will not run until Docker is installed.\n`);
133
163
  }
134
164
 
135
165
  if (!prerequisites.npm || !prerequisites.npx || !prerequisites.docker) {
@@ -144,6 +174,18 @@ function createLineInterface() {
144
174
  });
145
175
  }
146
176
 
177
+ function createSummary(templateName, targetDirectory, generatedInCurrentDirectory, demoAppsManagedByTemplate) {
178
+ return {
179
+ templateName,
180
+ targetDirectory,
181
+ generatedInCurrentDirectory,
182
+ demoAppsManagedByTemplate,
183
+ npmInstall: "not-run",
184
+ playwrightInstall: "not-run",
185
+ testRun: "not-run"
186
+ };
187
+ }
188
+
147
189
  function askQuestion(prompt) {
148
190
  const lineInterface = createLineInterface();
149
191
 
@@ -446,7 +488,7 @@ function getCommandName(base) {
446
488
 
447
489
  function printPlaywrightInstallRecovery(targetDirectory) {
448
490
  process.stdout.write(`
449
- Playwright browser installation did not complete.
491
+ ${colors.yellow("Playwright browser installation did not complete.")}
450
492
 
451
493
  Common cause:
452
494
  Missing OS packages required to run Playwright browsers.
@@ -487,28 +529,79 @@ function runCommand(command, args, cwd) {
487
529
  function printSuccess(templateName, targetDirectory, generatedInCurrentDirectory) {
488
530
  const template = getTemplate(templateName);
489
531
 
490
- process.stdout.write(`\nSuccess
532
+ process.stdout.write(`\n${colors.green(colors.bold("Success"))}
491
533
  Generated ${template ? template.label : templateName} in ${targetDirectory}
492
534
  \n`);
493
535
 
494
536
  if (!generatedInCurrentDirectory) {
495
- process.stdout.write(`Change directory first:\n cd ${path.relative(process.cwd(), targetDirectory) || "."}\n\n`);
537
+ process.stdout.write(`${colors.cyan("Change directory first:")}\n cd ${path.relative(process.cwd(), targetDirectory) || "."}\n\n`);
496
538
  }
497
539
  }
498
540
 
499
- function printNextSteps(targetDirectory, generatedInCurrentDirectory) {
500
- process.stdout.write("Next steps:\n");
541
+ function printNextSteps(summary) {
542
+ const steps = [];
501
543
 
502
- if (!generatedInCurrentDirectory) {
503
- process.stdout.write(` cd ${path.relative(process.cwd(), targetDirectory) || "."}\n`);
544
+ if (!summary.generatedInCurrentDirectory) {
545
+ steps.push(`cd ${path.relative(process.cwd(), summary.targetDirectory) || "."}`);
546
+ }
547
+
548
+ if (summary.npmInstall !== "completed") {
549
+ steps.push("npm install");
504
550
  }
505
551
 
506
- process.stdout.write(" npm install\n");
507
- process.stdout.write(" npx playwright install\n");
508
- process.stdout.write(" npm test\n");
552
+ if (summary.playwrightInstall !== "completed") {
553
+ steps.push("npx playwright install");
554
+ }
555
+
556
+ if (summary.testRun !== "completed") {
557
+ steps.push("npm test");
558
+ }
559
+
560
+ if (steps.length > 0) {
561
+ process.stdout.write(`${colors.cyan("Next steps:")}\n`);
562
+ for (const step of steps) {
563
+ process.stdout.write(` ${step}\n`);
564
+ }
565
+ process.stdout.write("\n");
566
+ }
567
+
568
+ if (summary.demoAppsManagedByTemplate) {
569
+ process.stdout.write(
570
+ `${colors.yellow(colors.bold("Demo apps included:"))} sample tests run against bundled demo apps in local ${colors.bold("dev")}. Delete or replace ${colors.bold("demo-apps/")} if you do not want them.\n`
571
+ );
572
+ }
573
+
574
+ process.stdout.write(`${colors.green(colors.bold("Happy testing."))}\n`);
575
+ }
576
+
577
+ function formatStatus(status) {
578
+ switch (status) {
579
+ case "completed":
580
+ return colors.green("completed");
581
+ case "skipped":
582
+ return colors.dim("skipped");
583
+ case "unavailable":
584
+ return colors.yellow("unavailable");
585
+ case "manual-recovery":
586
+ return colors.yellow("manual recovery required");
587
+ default:
588
+ return colors.dim("not run");
589
+ }
590
+ }
591
+
592
+ function printSummary(summary) {
593
+ process.stdout.write(`\n${colors.bold("Summary")}\n`);
594
+ process.stdout.write(` Template: ${summary.templateName}\n`);
595
+ process.stdout.write(` Target: ${summary.targetDirectory}\n`);
596
+ process.stdout.write(
597
+ ` Demo apps: ${summary.demoAppsManagedByTemplate ? "bundled and auto-started in dev when using default local URLs" : "external application required"}\n`
598
+ );
599
+ process.stdout.write(` npm install: ${formatStatus(summary.npmInstall)}\n`);
600
+ process.stdout.write(` Playwright browser install: ${formatStatus(summary.playwrightInstall)}\n`);
601
+ process.stdout.write(` npm test: ${formatStatus(summary.testRun)}\n`);
509
602
  }
510
603
 
511
- async function runPostGenerateActions(targetDirectory) {
604
+ async function runPostGenerateActions(targetDirectory, summary) {
512
605
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
513
606
  return;
514
607
  }
@@ -520,9 +613,13 @@ async function runPostGenerateActions(targetDirectory) {
520
613
 
521
614
  if (shouldInstallDependencies) {
522
615
  await runCommand("npm", ["install"], targetDirectory);
616
+ summary.npmInstall = "completed";
617
+ } else {
618
+ summary.npmInstall = "skipped";
523
619
  }
524
620
  } else {
525
- process.stdout.write("Skipping npm install prompt because npm is not available.\n");
621
+ process.stdout.write(`${colors.yellow("Skipping")} npm install prompt because npm is not available.\n`);
622
+ summary.npmInstall = "unavailable";
526
623
  }
527
624
 
528
625
  if (prerequisites.npx) {
@@ -531,7 +628,9 @@ async function runPostGenerateActions(targetDirectory) {
531
628
  if (shouldInstallPlaywright) {
532
629
  try {
533
630
  await runCommand("npx", ["playwright", "install"], targetDirectory);
631
+ summary.playwrightInstall = "completed";
534
632
  } catch (error) {
633
+ summary.playwrightInstall = "manual-recovery";
535
634
  printPlaywrightInstallRecovery(targetDirectory);
536
635
 
537
636
  const shouldContinue = await askYesNo("Continue without completing Playwright browser install?", true);
@@ -540,9 +639,12 @@ async function runPostGenerateActions(targetDirectory) {
540
639
  throw error;
541
640
  }
542
641
  }
642
+ } else {
643
+ summary.playwrightInstall = "skipped";
543
644
  }
544
645
  } else {
545
- process.stdout.write("Skipping Playwright browser install prompt because npx is not available.\n");
646
+ process.stdout.write(`${colors.yellow("Skipping")} Playwright browser install prompt because npx is not available.\n`);
647
+ summary.playwrightInstall = "unavailable";
546
648
  }
547
649
 
548
650
  if (prerequisites.npm) {
@@ -550,9 +652,13 @@ async function runPostGenerateActions(targetDirectory) {
550
652
 
551
653
  if (shouldRunTests) {
552
654
  await runCommand("npm", ["test"], targetDirectory);
655
+ summary.testRun = "completed";
656
+ } else {
657
+ summary.testRun = "skipped";
553
658
  }
554
659
  } else {
555
- process.stdout.write("Skipping npm test prompt because npm is not available.\n");
660
+ process.stdout.write(`${colors.yellow("Skipping")} npm test prompt because npm is not available.\n`);
661
+ summary.testRun = "unavailable";
556
662
  }
557
663
  }
558
664
 
@@ -567,11 +673,13 @@ async function main() {
567
673
  }
568
674
 
569
675
  const { templateName, targetDirectory, generatedInCurrentDirectory } = await resolveScaffoldArgs(args);
676
+ const summary = createSummary(templateName, targetDirectory, generatedInCurrentDirectory, true);
570
677
  printPrerequisiteWarnings(collectPrerequisites());
571
678
  await scaffoldProject(templateName, targetDirectory);
572
679
  printSuccess(templateName, targetDirectory, generatedInCurrentDirectory);
573
- await runPostGenerateActions(targetDirectory);
574
- printNextSteps(targetDirectory, generatedInCurrentDirectory);
680
+ await runPostGenerateActions(targetDirectory, summary);
681
+ printSummary(summary);
682
+ printNextSteps(summary);
575
683
  }
576
684
 
577
685
  main().catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toolstackhq/create-qa-patterns",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "CLI for generating QA framework templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,6 +35,7 @@ This is a Playwright + TypeScript automation framework template for UI and API t
35
35
  - page objects in `pages/` own locators and user actions
36
36
  - runtime config is loaded from `config/runtime-config.ts`
37
37
  - application URLs and credentials are resolved from `TEST_ENV`
38
+ - bundled demo apps auto-start during `npm test` in local `dev` mode when the default local URLs are in use
38
39
  - reports and artifacts are written under `reports/`, `allure-results/`, and `test-results/`
39
40
 
40
41
  ## Project structure
@@ -64,22 +65,22 @@ playwright-template
64
65
  npm install
65
66
  ```
66
67
 
67
- 2. Start the target apps you want the tests to hit.
68
-
69
- For the local demo apps from the root repo:
68
+ 2. Run tests.
70
69
 
71
70
  ```bash
72
- npm run dev:ui
71
+ npm test
73
72
  ```
74
73
 
74
+ In local `dev`, the template starts its bundled demo apps automatically before the tests run.
75
+
76
+ If you want to run the demo apps manually for debugging:
77
+
75
78
  ```bash
76
- npm run dev:api
79
+ npm run demo:ui
77
80
  ```
78
81
 
79
- 3. Run tests.
80
-
81
82
  ```bash
82
- npm test
83
+ npm run demo:api
83
84
  ```
84
85
 
85
86
  Default local values:
@@ -137,6 +138,12 @@ STAGING_APP_PASSWORD=my-password \
137
138
  npx playwright test
138
139
  ```
139
140
 
141
+ If you want to disable the bundled local demo apps even in `dev`, use:
142
+
143
+ ```bash
144
+ PW_DISABLE_LOCAL_DEMO_APPS=true npm test
145
+ ```
146
+
140
147
  If your team uses a real secret system later, replace the implementation behind `config/secret-manager.ts`.
141
148
 
142
149
  ## Main commands
@@ -146,6 +153,8 @@ npm test
146
153
  npm run test:smoke
147
154
  npm run test:regression
148
155
  npm run test:critical
156
+ npm run demo:ui
157
+ npm run demo:api
149
158
  npm run lint
150
159
  npm run typecheck
151
160
  npm run report:playwright
@@ -203,6 +212,7 @@ test("do something @smoke", async ({ dataFactory, loginPage }) => {
203
212
 
204
213
  Common extension points:
205
214
 
215
+ - update or replace the bundled demo apps under `demo-apps/`
206
216
  - add page objects under `pages/`
207
217
  - add reusable UI pieces under `components/`
208
218
  - extend fixtures in `fixtures/test-fixtures.ts`
@@ -0,0 +1,36 @@
1
+ const express = require("express");
2
+
3
+ const { createPerson, state } = require("./store");
4
+
5
+ const app = express();
6
+ const host = process.env.HOST || "0.0.0.0";
7
+ const port = Number(process.env.PORT || "3001");
8
+
9
+ app.use(express.json());
10
+
11
+ app.get("/health", (_request, response) => {
12
+ response.json({ status: "ok" });
13
+ });
14
+
15
+ app.get("/people", (_request, response) => {
16
+ response.json(state.people);
17
+ });
18
+
19
+ app.post("/people", (request, response) => {
20
+ const { name, role, email } = request.body;
21
+ if (!name || !role || !email) {
22
+ response.status(400).json({ error: "name, role, and email are required" });
23
+ return;
24
+ }
25
+
26
+ try {
27
+ const person = createPerson(request.body);
28
+ response.status(201).json(person);
29
+ } catch (error) {
30
+ response.status(409).json({ error: error.message });
31
+ }
32
+ });
33
+
34
+ app.listen(port, host, () => {
35
+ console.log(`API demo server listening on http://${host}:${port}`);
36
+ });
@@ -0,0 +1,37 @@
1
+ const state = {
2
+ people: [],
3
+ counters: {
4
+ person: 0
5
+ }
6
+ };
7
+
8
+ function nextId() {
9
+ state.counters.person += 1;
10
+ return `person-${String(state.counters.person).padStart(4, "0")}`;
11
+ }
12
+
13
+ function getPerson(personId) {
14
+ return state.people.find((person) => person.personId === personId);
15
+ }
16
+
17
+ function createPerson(payload) {
18
+ const personId = payload.personId || nextId();
19
+ if (getPerson(personId)) {
20
+ throw new Error(`Person ${personId} already exists`);
21
+ }
22
+
23
+ const person = {
24
+ personId,
25
+ name: payload.name,
26
+ role: payload.role,
27
+ email: payload.email
28
+ };
29
+
30
+ state.people.push(person);
31
+ return person;
32
+ }
33
+
34
+ module.exports = {
35
+ state,
36
+ createPerson
37
+ };
@@ -0,0 +1,120 @@
1
+ body {
2
+ margin: 0;
3
+ font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
4
+ background: linear-gradient(180deg, #f5f7fb 0%, #eef2f7 100%);
5
+ color: #102038;
6
+ }
7
+
8
+ .layout {
9
+ max-width: 1080px;
10
+ margin: 0 auto;
11
+ padding: 32px 24px 48px;
12
+ }
13
+
14
+ .header {
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: space-between;
18
+ gap: 16px;
19
+ margin-bottom: 24px;
20
+ }
21
+
22
+ .brand {
23
+ font-size: 1.5rem;
24
+ font-weight: 700;
25
+ }
26
+
27
+ .nav {
28
+ display: flex;
29
+ gap: 12px;
30
+ flex-wrap: wrap;
31
+ }
32
+
33
+ .nav a {
34
+ text-decoration: none;
35
+ color: #102038;
36
+ background: #d8e6ff;
37
+ padding: 10px 14px;
38
+ border-radius: 999px;
39
+ }
40
+
41
+ .card-grid {
42
+ display: grid;
43
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
44
+ gap: 16px;
45
+ margin-bottom: 24px;
46
+ }
47
+
48
+ .card,
49
+ .panel {
50
+ background: #ffffff;
51
+ border-radius: 18px;
52
+ padding: 20px;
53
+ box-shadow: 0 20px 40px rgba(16, 32, 56, 0.08);
54
+ }
55
+
56
+ .panel-grid {
57
+ display: grid;
58
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
59
+ gap: 24px;
60
+ }
61
+
62
+ form {
63
+ display: grid;
64
+ gap: 14px;
65
+ }
66
+
67
+ label {
68
+ display: grid;
69
+ gap: 6px;
70
+ font-weight: 600;
71
+ }
72
+
73
+ input,
74
+ select,
75
+ button {
76
+ font: inherit;
77
+ padding: 10px 12px;
78
+ border-radius: 12px;
79
+ border: 1px solid #c4d3eb;
80
+ }
81
+
82
+ button {
83
+ background: #174ea6;
84
+ color: #ffffff;
85
+ border: none;
86
+ font-weight: 700;
87
+ cursor: pointer;
88
+ }
89
+
90
+ table {
91
+ width: 100%;
92
+ border-collapse: collapse;
93
+ }
94
+
95
+ th,
96
+ td {
97
+ text-align: left;
98
+ padding: 12px 8px;
99
+ border-bottom: 1px solid #e1e9f5;
100
+ }
101
+
102
+ .flash-message {
103
+ background: #dff6e7;
104
+ border: 1px solid #9dd5ae;
105
+ color: #174f2c;
106
+ padding: 12px 14px;
107
+ border-radius: 14px;
108
+ margin-bottom: 16px;
109
+ }
110
+
111
+ .login-shell {
112
+ min-height: 100vh;
113
+ display: grid;
114
+ place-items: center;
115
+ padding: 24px;
116
+ }
117
+
118
+ .login-card {
119
+ width: min(420px, 100%);
120
+ }
@@ -0,0 +1,149 @@
1
+ const fs = require("node:fs");
2
+ const http = require("node:http");
3
+ const path = require("node:path");
4
+ const querystring = require("node:querystring");
5
+
6
+ const { createPerson, getPeople, state } = require("./store");
7
+ const { layout, loginPage, peoplePage } = require("./templates");
8
+
9
+ const host = process.env.HOST || "0.0.0.0";
10
+ const port = Number(process.env.PORT || "3000");
11
+
12
+ function readBody(request) {
13
+ return new Promise((resolve, reject) => {
14
+ let body = "";
15
+ request.on("data", (chunk) => {
16
+ body += chunk.toString();
17
+ });
18
+ request.on("end", () => resolve(querystring.parse(body)));
19
+ request.on("error", reject);
20
+ });
21
+ }
22
+
23
+ function parseCookies(request) {
24
+ const header = request.headers.cookie;
25
+ if (!header) {
26
+ return {};
27
+ }
28
+
29
+ return header.split(";").reduce((cookies, entry) => {
30
+ const [key, value] = entry.trim().split("=");
31
+ cookies[key] = value;
32
+ return cookies;
33
+ }, {});
34
+ }
35
+
36
+ function redirect(response, location) {
37
+ response.writeHead(302, { Location: location });
38
+ response.end();
39
+ }
40
+
41
+ function sendHtml(response, html) {
42
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
43
+ response.end(html);
44
+ }
45
+
46
+ function sendJson(response, payload) {
47
+ response.writeHead(200, { "Content-Type": "application/json" });
48
+ response.end(JSON.stringify(payload));
49
+ }
50
+
51
+ function isAuthenticated(request) {
52
+ return parseCookies(request).session === "authenticated";
53
+ }
54
+
55
+ function protectedRoute(request, response) {
56
+ if (!isAuthenticated(request)) {
57
+ redirect(response, "/login");
58
+ return false;
59
+ }
60
+
61
+ return true;
62
+ }
63
+
64
+ function pageMessage(url) {
65
+ return new URL(url, "http://127.0.0.1").searchParams.get("message") || "";
66
+ }
67
+
68
+ const server = http.createServer(async (request, response) => {
69
+ const url = new URL(request.url, "http://127.0.0.1");
70
+
71
+ if (request.method === "GET" && url.pathname === "/styles.css") {
72
+ const cssPath = path.join(__dirname, "..", "public", "styles.css");
73
+ response.writeHead(200, { "Content-Type": "text/css; charset=utf-8" });
74
+ response.end(fs.readFileSync(cssPath, "utf8"));
75
+ return;
76
+ }
77
+
78
+ if (request.method === "GET" && url.pathname === "/health") {
79
+ sendJson(response, { status: "ok", people: state.people.length });
80
+ return;
81
+ }
82
+
83
+ if (request.method === "GET" && url.pathname === "/") {
84
+ redirect(response, "/login");
85
+ return;
86
+ }
87
+
88
+ if (request.method === "GET" && url.pathname === "/login") {
89
+ sendHtml(response, loginPage());
90
+ return;
91
+ }
92
+
93
+ if (request.method === "POST" && url.pathname === "/login") {
94
+ const body = await readBody(request);
95
+ if (
96
+ body.username === state.credentials.username &&
97
+ body.password === state.credentials.password
98
+ ) {
99
+ response.writeHead(302, {
100
+ Location: "/people",
101
+ "Set-Cookie": "session=authenticated; HttpOnly; Path=/; SameSite=Lax"
102
+ });
103
+ response.end();
104
+ return;
105
+ }
106
+
107
+ sendHtml(response, loginPage("Invalid credentials"));
108
+ return;
109
+ }
110
+
111
+ if (request.method === "GET" && url.pathname === "/people") {
112
+ if (!protectedRoute(request, response)) {
113
+ return;
114
+ }
115
+
116
+ sendHtml(
117
+ response,
118
+ layout({
119
+ title: "People",
120
+ body: peoplePage(getPeople(url.searchParams.get("search") || "")),
121
+ flashMessage: pageMessage(request.url),
122
+ username: state.credentials.username
123
+ })
124
+ );
125
+ return;
126
+ }
127
+
128
+ if (request.method === "POST" && url.pathname === "/people") {
129
+ if (!protectedRoute(request, response)) {
130
+ return;
131
+ }
132
+
133
+ try {
134
+ const body = await readBody(request);
135
+ createPerson(body);
136
+ redirect(response, "/people?message=Person%20added");
137
+ } catch (error) {
138
+ redirect(response, `/people?message=${encodeURIComponent(error.message)}`);
139
+ }
140
+ return;
141
+ }
142
+
143
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
144
+ response.end("Not found");
145
+ });
146
+
147
+ server.listen(port, host, () => {
148
+ console.log(`UI demo app listening on http://${host}:${port}`);
149
+ });
@@ -0,0 +1,43 @@
1
+ const state = {
2
+ credentials: {
3
+ username: process.env.UI_DEMO_USERNAME || "tester",
4
+ password: process.env.UI_DEMO_PASSWORD || "Password123!"
5
+ },
6
+ people: []
7
+ };
8
+
9
+ function findPerson(personId) {
10
+ return state.people.find((person) => person.personId === personId);
11
+ }
12
+
13
+ function createPerson(person) {
14
+ if (findPerson(person.personId)) {
15
+ throw new Error(`Person ${person.personId} already exists`);
16
+ }
17
+
18
+ state.people.push({
19
+ personId: person.personId,
20
+ name: person.name,
21
+ role: person.role,
22
+ email: person.email
23
+ });
24
+ }
25
+
26
+ function getPeople(search = "") {
27
+ const query = String(search).trim().toLowerCase();
28
+ if (!query) {
29
+ return state.people;
30
+ }
31
+
32
+ return state.people.filter((person) => {
33
+ return [person.name, person.role, person.email].some((value) =>
34
+ value.toLowerCase().includes(query)
35
+ );
36
+ });
37
+ }
38
+
39
+ module.exports = {
40
+ state,
41
+ createPerson,
42
+ getPeople
43
+ };
@@ -0,0 +1,121 @@
1
+ function layout({ title, body, flashMessage = "", username = "tester" }) {
2
+ return `<!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>${title}</title>
8
+ <link rel="stylesheet" href="/styles.css" />
9
+ </head>
10
+ <body>
11
+ <div class="layout">
12
+ <header class="header">
13
+ <div class="brand">UI Patterns Demo</div>
14
+ <nav class="nav" aria-label="Primary">
15
+ <a href="/people">People</a>
16
+ </nav>
17
+ </header>
18
+ ${flashMessage ? `<div class="flash-message" data-testid="flash-message" role="status">${flashMessage}</div>` : ""}
19
+ <p data-testid="welcome-message">Signed in as ${username}</p>
20
+ ${body}
21
+ </div>
22
+ </body>
23
+ </html>`;
24
+ }
25
+
26
+ function loginPage(errorMessage = "") {
27
+ return `<!DOCTYPE html>
28
+ <html lang="en">
29
+ <head>
30
+ <meta charset="UTF-8" />
31
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
32
+ <title>Login</title>
33
+ <link rel="stylesheet" href="/styles.css" />
34
+ </head>
35
+ <body>
36
+ <main class="login-shell">
37
+ <section class="card login-card">
38
+ <h1>Login</h1>
39
+ <p>Keep the example intentionally small: sign in, add one person, assert the list.</p>
40
+ ${errorMessage ? `<div class="flash-message" role="status">${errorMessage}</div>` : ""}
41
+ <form action="/login" method="post">
42
+ <label for="username">
43
+ Username
44
+ <input id="username" name="username" type="text" autocomplete="username" required />
45
+ </label>
46
+ <label for="password">
47
+ Password
48
+ <input id="password" name="password" type="password" autocomplete="current-password" required />
49
+ </label>
50
+ <button type="submit">Sign in</button>
51
+ </form>
52
+ </section>
53
+ </main>
54
+ </body>
55
+ </html>`;
56
+ }
57
+
58
+ function peoplePage(people) {
59
+ const rows = people
60
+ .map((person) => {
61
+ return `<tr data-testid="person-row-${person.personId}">
62
+ <td>${person.name}</td>
63
+ <td>${person.role}</td>
64
+ <td>${person.email}</td>
65
+ </tr>`;
66
+ })
67
+ .join("");
68
+
69
+ return `
70
+ <section class="panel-grid">
71
+ <section class="panel">
72
+ <h1>People</h1>
73
+ <form action="/people" method="post">
74
+ <label for="personId">
75
+ Person ID
76
+ <input id="personId" name="personId" type="text" required />
77
+ </label>
78
+ <label for="name">
79
+ Name
80
+ <input id="name" name="name" type="text" required />
81
+ </label>
82
+ <label for="role">
83
+ Role
84
+ <input id="role" name="role" type="text" required />
85
+ </label>
86
+ <label for="email">
87
+ Email
88
+ <input id="email" name="email" type="email" required />
89
+ </label>
90
+ <button type="submit">Add person</button>
91
+ </form>
92
+ </section>
93
+ <section class="panel">
94
+ <h2>Directory</h2>
95
+ <form action="/people" method="get">
96
+ <label for="search">
97
+ Search
98
+ <input id="search" name="search" type="text" placeholder="Search people" />
99
+ </label>
100
+ <button type="submit">Apply filter</button>
101
+ </form>
102
+ <table>
103
+ <thead>
104
+ <tr>
105
+ <th>Name</th>
106
+ <th>Role</th>
107
+ <th>Email</th>
108
+ </tr>
109
+ </thead>
110
+ <tbody>${rows}</tbody>
111
+ </table>
112
+ </section>
113
+ </section>
114
+ `;
115
+ }
116
+
117
+ module.exports = {
118
+ layout,
119
+ loginPage,
120
+ peoplePage
121
+ };
@@ -19,6 +19,19 @@ export default [
19
19
  ]
20
20
  },
21
21
  js.configs.recommended,
22
+ {
23
+ files: ["demo-apps/**/*.js"],
24
+ languageOptions: {
25
+ globals: {
26
+ __dirname: "readonly",
27
+ console: "readonly",
28
+ module: "readonly",
29
+ process: "readonly",
30
+ require: "readonly",
31
+ URL: "readonly"
32
+ }
33
+ }
34
+ },
22
35
  {
23
36
  files: ["**/*.ts"],
24
37
  languageOptions: {
@@ -8,6 +8,8 @@
8
8
  "test:smoke": "playwright test --grep @smoke",
9
9
  "test:regression": "playwright test --grep @regression",
10
10
  "test:critical": "playwright test --grep @critical",
11
+ "demo:ui": "node ./demo-apps/ui-demo-app/src/server.js",
12
+ "demo:api": "node ./demo-apps/api-demo-server/src/server.js",
11
13
  "lint": "eslint .",
12
14
  "typecheck": "tsc --noEmit",
13
15
  "report:playwright": "playwright show-report reports/html",
@@ -18,6 +20,7 @@
18
20
  "dependencies": {
19
21
  "@faker-js/faker": "^8.4.1",
20
22
  "dotenv": "^16.4.5",
23
+ "express": "^4.21.0",
21
24
  "zod": "^3.23.8"
22
25
  },
23
26
  "devDependencies": {
@@ -3,6 +3,11 @@ import { defineConfig, devices } from "@playwright/test";
3
3
  import { loadRuntimeConfig } from "./config/runtime-config";
4
4
 
5
5
  const runtimeConfig = loadRuntimeConfig();
6
+ const shouldAutoStartDemoApps =
7
+ runtimeConfig.testEnv === "dev" &&
8
+ runtimeConfig.uiBaseUrl === "http://127.0.0.1:3000" &&
9
+ runtimeConfig.apiBaseUrl === "http://127.0.0.1:3001" &&
10
+ process.env.PW_DISABLE_LOCAL_DEMO_APPS !== "true";
6
11
 
7
12
  export default defineConfig({
8
13
  testDir: "./tests",
@@ -35,6 +40,22 @@ export default defineConfig({
35
40
  testRunId: runtimeConfig.testRunId,
36
41
  apiBaseUrl: runtimeConfig.apiBaseUrl
37
42
  },
43
+ webServer: shouldAutoStartDemoApps
44
+ ? [
45
+ {
46
+ command: "npm run demo:ui",
47
+ url: `${runtimeConfig.uiBaseUrl}/health`,
48
+ reuseExistingServer: !process.env.CI,
49
+ timeout: 30_000
50
+ },
51
+ {
52
+ command: "npm run demo:api",
53
+ url: `${runtimeConfig.apiBaseUrl}/health`,
54
+ reuseExistingServer: !process.env.CI,
55
+ timeout: 30_000
56
+ }
57
+ ]
58
+ : undefined,
38
59
  projects: [
39
60
  {
40
61
  name: "chromium",