@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 +97 -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/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
|
|
|
@@ -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(`\
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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",
|