@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 +2 -0
- package/index.js +127 -13
- package/package.json +1 -1
- package/templates/playwright-template/README.md +18 -8
- package/templates/playwright-template/demo-apps/api-demo-server/src/server.js +36 -0
- package/templates/playwright-template/demo-apps/api-demo-server/src/store.js +37 -0
- package/templates/playwright-template/demo-apps/ui-demo-app/public/styles.css +120 -0
- package/templates/playwright-template/demo-apps/ui-demo-app/src/server.js +149 -0
- package/templates/playwright-template/demo-apps/ui-demo-app/src/store.js +43 -0
- package/templates/playwright-template/demo-apps/ui-demo-app/src/templates.js +121 -0
- package/templates/playwright-template/eslint.config.mjs +13 -0
- package/templates/playwright-template/package.json +3 -0
- package/templates/playwright-template/playwright.config.ts +21 -0
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(
|
|
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(`\
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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.
|
|
68
|
-
|
|
69
|
-
For the local demo apps from the root repo:
|
|
68
|
+
2. Run tests.
|
|
70
69
|
|
|
71
70
|
```bash
|
|
72
|
-
npm
|
|
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
|
|
79
|
+
npm run demo:ui
|
|
77
80
|
```
|
|
78
81
|
|
|
79
|
-
3. Run tests.
|
|
80
|
-
|
|
81
82
|
```bash
|
|
82
|
-
npm
|
|
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",
|