@toolstackhq/create-qa-patterns 1.0.4 → 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/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,17 +529,17 @@ 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
541
  function printNextSteps(targetDirectory, generatedInCurrentDirectory) {
500
- process.stdout.write("Next steps:\n");
542
+ process.stdout.write(`${colors.cyan("Next steps:")}\n`);
501
543
 
502
544
  if (!generatedInCurrentDirectory) {
503
545
  process.stdout.write(` cd ${path.relative(process.cwd(), targetDirectory) || "."}\n`);
@@ -508,7 +550,34 @@ function printNextSteps(targetDirectory, generatedInCurrentDirectory) {
508
550
  process.stdout.write(" npm test\n");
509
551
  }
510
552
 
511
- 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) {
512
581
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
513
582
  return;
514
583
  }
@@ -520,9 +589,13 @@ async function runPostGenerateActions(targetDirectory) {
520
589
 
521
590
  if (shouldInstallDependencies) {
522
591
  await runCommand("npm", ["install"], targetDirectory);
592
+ summary.npmInstall = "completed";
593
+ } else {
594
+ summary.npmInstall = "skipped";
523
595
  }
524
596
  } else {
525
- 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";
526
599
  }
527
600
 
528
601
  if (prerequisites.npx) {
@@ -531,7 +604,9 @@ async function runPostGenerateActions(targetDirectory) {
531
604
  if (shouldInstallPlaywright) {
532
605
  try {
533
606
  await runCommand("npx", ["playwright", "install"], targetDirectory);
607
+ summary.playwrightInstall = "completed";
534
608
  } catch (error) {
609
+ summary.playwrightInstall = "manual-recovery";
535
610
  printPlaywrightInstallRecovery(targetDirectory);
536
611
 
537
612
  const shouldContinue = await askYesNo("Continue without completing Playwright browser install?", true);
@@ -540,9 +615,12 @@ async function runPostGenerateActions(targetDirectory) {
540
615
  throw error;
541
616
  }
542
617
  }
618
+ } else {
619
+ summary.playwrightInstall = "skipped";
543
620
  }
544
621
  } else {
545
- 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";
546
624
  }
547
625
 
548
626
  if (prerequisites.npm) {
@@ -550,9 +628,13 @@ async function runPostGenerateActions(targetDirectory) {
550
628
 
551
629
  if (shouldRunTests) {
552
630
  await runCommand("npm", ["test"], targetDirectory);
631
+ summary.testRun = "completed";
632
+ } else {
633
+ summary.testRun = "skipped";
553
634
  }
554
635
  } else {
555
- 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";
556
638
  }
557
639
  }
558
640
 
@@ -567,10 +649,12 @@ async function main() {
567
649
  }
568
650
 
569
651
  const { templateName, targetDirectory, generatedInCurrentDirectory } = await resolveScaffoldArgs(args);
652
+ const summary = createSummary(templateName, targetDirectory, generatedInCurrentDirectory, true);
570
653
  printPrerequisiteWarnings(collectPrerequisites());
571
654
  await scaffoldProject(templateName, targetDirectory);
572
655
  printSuccess(templateName, targetDirectory, generatedInCurrentDirectory);
573
- await runPostGenerateActions(targetDirectory);
656
+ await runPostGenerateActions(targetDirectory, summary);
657
+ printSummary(summary);
574
658
  printNextSteps(targetDirectory, generatedInCurrentDirectory);
575
659
  }
576
660
 
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.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",