@toolstackhq/create-qa-patterns 1.0.3 → 1.0.5

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
@@ -50,3 +50,5 @@ The CLI checks:
50
50
  - `npm` availability for install and test actions
51
51
  - `npx` availability for Playwright browser installation
52
52
  - `docker` availability and warns if it is missing
53
+
54
+ If `npx playwright install` fails because the host is missing browser dependencies, the CLI keeps the generated project and prints the recovery steps instead of treating scaffold generation as failed.
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
 
@@ -444,6 +486,26 @@ function getCommandName(base) {
444
486
  return base;
445
487
  }
446
488
 
489
+ function printPlaywrightInstallRecovery(targetDirectory) {
490
+ process.stdout.write(`
491
+ ${colors.yellow("Playwright browser installation did not complete.")}
492
+
493
+ Common cause:
494
+ Missing OS packages required to run Playwright browsers.
495
+
496
+ Recommended next steps:
497
+ cd ${path.relative(process.cwd(), targetDirectory) || "."}
498
+ sudo npx playwright install-deps
499
+ npx playwright install
500
+
501
+ If you already know the missing package name, install it with your system package manager and then rerun:
502
+ npx playwright install
503
+
504
+ The template was generated successfully. You can complete browser setup later.
505
+
506
+ `);
507
+ }
508
+
447
509
  function runCommand(command, args, cwd) {
448
510
  return new Promise((resolve, reject) => {
449
511
  const child = spawn(getCommandName(command), args, {
@@ -467,17 +529,17 @@ function runCommand(command, args, cwd) {
467
529
  function printSuccess(templateName, targetDirectory, generatedInCurrentDirectory) {
468
530
  const template = getTemplate(templateName);
469
531
 
470
- process.stdout.write(`\nSuccess
532
+ process.stdout.write(`\n${colors.green(colors.bold("Success"))}
471
533
  Generated ${template ? template.label : templateName} in ${targetDirectory}
472
534
  \n`);
473
535
 
474
536
  if (!generatedInCurrentDirectory) {
475
- 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`);
476
538
  }
477
539
  }
478
540
 
479
541
  function printNextSteps(targetDirectory, generatedInCurrentDirectory) {
480
- process.stdout.write("Next steps:\n");
542
+ process.stdout.write(`${colors.cyan("Next steps:")}\n`);
481
543
 
482
544
  if (!generatedInCurrentDirectory) {
483
545
  process.stdout.write(` cd ${path.relative(process.cwd(), targetDirectory) || "."}\n`);
@@ -488,7 +550,34 @@ function printNextSteps(targetDirectory, generatedInCurrentDirectory) {
488
550
  process.stdout.write(" npm test\n");
489
551
  }
490
552
 
491
- async function runPostGenerateActions(targetDirectory) {
553
+ function formatStatus(status) {
554
+ switch (status) {
555
+ case "completed":
556
+ return colors.green("completed");
557
+ case "skipped":
558
+ return colors.dim("skipped");
559
+ case "unavailable":
560
+ return colors.yellow("unavailable");
561
+ case "manual-recovery":
562
+ return colors.yellow("manual recovery required");
563
+ default:
564
+ return colors.dim("not run");
565
+ }
566
+ }
567
+
568
+ function printSummary(summary) {
569
+ process.stdout.write(`\n${colors.bold("Summary")}\n`);
570
+ process.stdout.write(` Template: ${summary.templateName}\n`);
571
+ process.stdout.write(` Target: ${summary.targetDirectory}\n`);
572
+ process.stdout.write(
573
+ ` Demo apps: ${summary.demoAppsManagedByTemplate ? "bundled and auto-started in dev when using default local URLs" : "external application required"}\n`
574
+ );
575
+ process.stdout.write(` npm install: ${formatStatus(summary.npmInstall)}\n`);
576
+ process.stdout.write(` Playwright browser install: ${formatStatus(summary.playwrightInstall)}\n`);
577
+ process.stdout.write(` npm test: ${formatStatus(summary.testRun)}\n`);
578
+ }
579
+
580
+ async function runPostGenerateActions(targetDirectory, summary) {
492
581
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
493
582
  return;
494
583
  }
@@ -500,19 +589,38 @@ async function runPostGenerateActions(targetDirectory) {
500
589
 
501
590
  if (shouldInstallDependencies) {
502
591
  await runCommand("npm", ["install"], targetDirectory);
592
+ summary.npmInstall = "completed";
593
+ } else {
594
+ summary.npmInstall = "skipped";
503
595
  }
504
596
  } else {
505
- process.stdout.write("Skipping npm install prompt because npm is not available.\n");
597
+ process.stdout.write(`${colors.yellow("Skipping")} npm install prompt because npm is not available.\n`);
598
+ summary.npmInstall = "unavailable";
506
599
  }
507
600
 
508
601
  if (prerequisites.npx) {
509
602
  const shouldInstallPlaywright = await askYesNo("Run npx playwright install now?", true);
510
603
 
511
604
  if (shouldInstallPlaywright) {
512
- await runCommand("npx", ["playwright", "install"], targetDirectory);
605
+ try {
606
+ await runCommand("npx", ["playwright", "install"], targetDirectory);
607
+ summary.playwrightInstall = "completed";
608
+ } catch (error) {
609
+ summary.playwrightInstall = "manual-recovery";
610
+ printPlaywrightInstallRecovery(targetDirectory);
611
+
612
+ const shouldContinue = await askYesNo("Continue without completing Playwright browser install?", true);
613
+
614
+ if (!shouldContinue) {
615
+ throw error;
616
+ }
617
+ }
618
+ } else {
619
+ summary.playwrightInstall = "skipped";
513
620
  }
514
621
  } else {
515
- process.stdout.write("Skipping Playwright browser install prompt because npx is not available.\n");
622
+ process.stdout.write(`${colors.yellow("Skipping")} Playwright browser install prompt because npx is not available.\n`);
623
+ summary.playwrightInstall = "unavailable";
516
624
  }
517
625
 
518
626
  if (prerequisites.npm) {
@@ -520,9 +628,13 @@ async function runPostGenerateActions(targetDirectory) {
520
628
 
521
629
  if (shouldRunTests) {
522
630
  await runCommand("npm", ["test"], targetDirectory);
631
+ summary.testRun = "completed";
632
+ } else {
633
+ summary.testRun = "skipped";
523
634
  }
524
635
  } else {
525
- process.stdout.write("Skipping npm test prompt because npm is not available.\n");
636
+ process.stdout.write(`${colors.yellow("Skipping")} npm test prompt because npm is not available.\n`);
637
+ summary.testRun = "unavailable";
526
638
  }
527
639
  }
528
640
 
@@ -537,10 +649,12 @@ async function main() {
537
649
  }
538
650
 
539
651
  const { templateName, targetDirectory, generatedInCurrentDirectory } = await resolveScaffoldArgs(args);
652
+ const summary = createSummary(templateName, targetDirectory, generatedInCurrentDirectory, true);
540
653
  printPrerequisiteWarnings(collectPrerequisites());
541
654
  await scaffoldProject(templateName, targetDirectory);
542
655
  printSuccess(templateName, targetDirectory, generatedInCurrentDirectory);
543
- await runPostGenerateActions(targetDirectory);
656
+ await runPostGenerateActions(targetDirectory, summary);
657
+ printSummary(summary);
544
658
  printNextSteps(targetDirectory, generatedInCurrentDirectory);
545
659
  }
546
660
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toolstackhq/create-qa-patterns",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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",