qa-copilot-lab 0.1.0
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 +69 -0
- package/bin/cli.js +11 -0
- package/image/1TB External SSD.jpg +0 -0
- package/image/Bluetooth Speaker.jpg +0 -0
- package/image/Ethernet Cable 3m.jpg +0 -0
- package/image/Laptop Stand.jpg +0 -0
- package/image/Mechanical Keyboard.jpg +0 -0
- package/image/Noise Cancelling Headphones.jpg +0 -0
- package/image/USB-C Hub.jpg +0 -0
- package/image/Webcam 1080p.jpg +0 -0
- package/image/Wi-Fi 6 Router.jpg +0 -0
- package/image/wireless mouse.jpg +0 -0
- package/mouse.jpg +0 -0
- package/package.json +26 -0
- package/public/css/style.css +169 -0
- package/public/images/products/1.jpg +0 -0
- package/public/images/products/10.jpg +0 -0
- package/public/images/products/2.jpg +0 -0
- package/public/images/products/3.jpg +0 -0
- package/public/images/products/4.jpg +0 -0
- package/public/images/products/5.jpg +0 -0
- package/public/images/products/6.jpg +0 -0
- package/public/images/products/7.jpg +0 -0
- package/public/images/products/8.jpg +0 -0
- package/public/images/products/9.jpg +0 -0
- package/public/js/accordion.js +10 -0
- package/public/js/autocomplete.js +39 -0
- package/public/js/cart.js +52 -0
- package/public/js/confirm-dialog.js +8 -0
- package/public/js/drag-drop.js +44 -0
- package/public/js/reveal-answer.js +10 -0
- package/public/js/star-rating.js +38 -0
- package/public/js/tabs.js +14 -0
- package/public/js/theme-toggle.js +24 -0
- package/public/js/toast.js +10 -0
- package/qa-copilot-lab-0.1.0.tgz +0 -0
- package/src/chaos.js +18 -0
- package/src/db.js +97 -0
- package/src/middleware/session.js +16 -0
- package/src/routes/api.js +136 -0
- package/src/routes/auth.js +45 -0
- package/src/routes/devApi.js +56 -0
- package/src/routes/pages.js +125 -0
- package/src/seed.js +43 -0
- package/src/server.js +71 -0
- package/src/state.js +33 -0
- package/views/404.ejs +7 -0
- package/views/account.ejs +42 -0
- package/views/cart.ejs +42 -0
- package/views/catalog.ejs +69 -0
- package/views/checkout.ejs +49 -0
- package/views/home.ejs +42 -0
- package/views/invoice.ejs +18 -0
- package/views/locators.ejs +323 -0
- package/views/login.ejs +22 -0
- package/views/orders.ejs +33 -0
- package/views/partials/footer.ejs +18 -0
- package/views/partials/head.ejs +9 -0
- package/views/partials/header.ejs +39 -0
- package/views/payment-frame.ejs +26 -0
- package/views/product.ejs +77 -0
- package/views/register.ejs +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# QA Copilot Lab
|
|
2
|
+
|
|
3
|
+
A local practice target site for the *Playwright MCP for QA* course. It is a
|
|
4
|
+
small e-commerce app deliberately built so every common Playwright locator
|
|
5
|
+
strategy has a real place to be exercised — plus a chaos mode, seeded bugs,
|
|
6
|
+
and a reset API for self-healing and CI exercises.
|
|
7
|
+
|
|
8
|
+
## Run it
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install
|
|
12
|
+
npm start
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then open http://localhost:4173. Login with `qa_student` / `Passw0rd!`.
|
|
16
|
+
|
|
17
|
+
Flags:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm start -- --fresh # wipe local data and reseed
|
|
21
|
+
npm start -- --port=5000 # run on a different port
|
|
22
|
+
npm start -- --host=0.0.0.0 # bind every interface (reachable from other devices on the LAN)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Default binding is `127.0.0.1` (this machine only). Use `--host=0.0.0.0`
|
|
26
|
+
(or `HOST=0.0.0.0 npm start`) if you need to reach the app from another
|
|
27
|
+
device on the same network — e.g. testing from a phone, VM, or a
|
|
28
|
+
Playwright run inside a container. Anyone on that network can then reach
|
|
29
|
+
it, so only do this on networks you trust.
|
|
30
|
+
|
|
31
|
+
## Where your data lives
|
|
32
|
+
|
|
33
|
+
All data is stored in a single SQLite file at `~/.qa-copilot-lab/data.db`
|
|
34
|
+
(printed on every startup). Restarting the app does **not** erase it —
|
|
35
|
+
only an explicit reset does:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
curl -X POST http://localhost:4173/api/__reset
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Dev / test control API
|
|
42
|
+
|
|
43
|
+
| Method | Route | Purpose |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `POST` | `/api/__reset` | Restore seed data, clear cart, turn off chaos/bugs |
|
|
46
|
+
| `POST` | `/api/__chaos/:mode` | `off` \| `mild` (class names drift) \| `aggressive` (class **and** testid drift) |
|
|
47
|
+
| `POST` | `/api/__seed-bugs/:setId` | `off` \| `1` \| `2` — inject a known defect for exploratory testing |
|
|
48
|
+
| `POST` | `/api/__flaky/:mode` | `on` \| `off` — checkout gains latency, a 25% chance of a 500, and a real double-submit race |
|
|
49
|
+
| `GET` | `/api/__health` | Readiness check for CI |
|
|
50
|
+
|
|
51
|
+
## Locator practice
|
|
52
|
+
|
|
53
|
+
Visit `/locators` for thirty-six isolated examples: sixteen general locator
|
|
54
|
+
strategies (role, label, placeholder, alt text, title, testid, text match,
|
|
55
|
+
CSS attribute, XPath axes, chained filter, nth, frame locator, shadow DOM,
|
|
56
|
+
ARIA state, disabled state, native dialog) plus twenty ARIA roles for
|
|
57
|
+
`getByRole` (textbox, checkbox, combobox, slider, switch, navigation,
|
|
58
|
+
heading, list, listitem, table, row, cell, columnheader, dialog, alert,
|
|
59
|
+
progressbar, status, img, tab/tabpanel, search) — the same strategies used
|
|
60
|
+
throughout the real storefront pages.
|
|
61
|
+
|
|
62
|
+
## Why these choices
|
|
63
|
+
|
|
64
|
+
- **Node.js + Express + EJS** — no build step, nothing to compile beyond
|
|
65
|
+
`better-sqlite3`'s prebuilt binary.
|
|
66
|
+
- **SQLite in the user's home directory** — survives restarts, and `--fresh`
|
|
67
|
+
gives an explicit, deliberate way to wipe it.
|
|
68
|
+
- **No Docker, no bundled browser** — the app is a webpage; Playwright brings
|
|
69
|
+
its own browser.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { startServer } = require('../src/server');
|
|
3
|
+
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
const fresh = args.includes('--fresh');
|
|
6
|
+
const portArg = args.find((a) => a.startsWith('--port='));
|
|
7
|
+
const hostArg = args.find((a) => a.startsWith('--host='));
|
|
8
|
+
const port = portArg ? Number(portArg.split('=')[1]) : Number(process.env.PORT) || 4173;
|
|
9
|
+
const host = hostArg ? hostArg.split('=')[1] : process.env.HOST || '127.0.0.1';
|
|
10
|
+
|
|
11
|
+
startServer({ port, fresh, host });
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/mouse.jpg
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "qa-copilot-lab",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local practice target site for the Playwright MCP for QA course — every common locator strategy, a chaos mode for self-healing practice, seeded bugs for exploratory testing, and a reset API for CI.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"qa-copilot-lab": "bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "src/server.js",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "node bin/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"better-sqlite3": "^11.3.0",
|
|
17
|
+
"cookie-parser": "^1.4.6",
|
|
18
|
+
"ejs": "^3.1.10",
|
|
19
|
+
"express": "^4.19.2"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"devDependencies": {},
|
|
23
|
+
"keywords": [],
|
|
24
|
+
"author": "",
|
|
25
|
+
"type": "commonjs"
|
|
26
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--ink: #161a18;
|
|
3
|
+
--ink-soft: #4b544f;
|
|
4
|
+
--paper: #f1f3ef;
|
|
5
|
+
--paper-raised: #ffffff;
|
|
6
|
+
--line: #d9ddd6;
|
|
7
|
+
--accent: #1f7a5c;
|
|
8
|
+
--accent-soft: #e4efe9;
|
|
9
|
+
--warn: #b85c2c;
|
|
10
|
+
--warn-soft: #f5e6dc;
|
|
11
|
+
--danger: #a83c3c;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
html[data-theme='dark'] {
|
|
15
|
+
--ink: #edefea;
|
|
16
|
+
--ink-soft: #b6c0b9;
|
|
17
|
+
--paper: #131815;
|
|
18
|
+
--paper-raised: #1a201c;
|
|
19
|
+
--line: #2a322c;
|
|
20
|
+
--accent: #49b088;
|
|
21
|
+
--accent-soft: #1c2f27;
|
|
22
|
+
--warn: #e08a4f;
|
|
23
|
+
--warn-soft: #35281e;
|
|
24
|
+
--danger: #e07171;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
* { box-sizing: border-box; }
|
|
28
|
+
|
|
29
|
+
body {
|
|
30
|
+
margin: 0;
|
|
31
|
+
background: var(--paper);
|
|
32
|
+
color: var(--ink);
|
|
33
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
34
|
+
line-height: 1.5;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
a { color: var(--accent); }
|
|
38
|
+
code { font-family: "Courier New", monospace; background: var(--paper-raised); padding: 1px 5px; border-radius: 4px; }
|
|
39
|
+
|
|
40
|
+
/* ---- navbar ---- */
|
|
41
|
+
.navbar {
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
gap: 20px;
|
|
45
|
+
padding: 14px 24px;
|
|
46
|
+
background: var(--paper-raised);
|
|
47
|
+
border-bottom: 1px solid var(--line);
|
|
48
|
+
}
|
|
49
|
+
.brand { font-weight: 700; font-size: 18px; text-decoration: none; color: var(--ink); }
|
|
50
|
+
.search-form { flex: 1; max-width: 360px; }
|
|
51
|
+
.search-form input { width: 100%; padding: 8px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--paper); color: var(--ink); }
|
|
52
|
+
.nav-links { display: flex; gap: 14px; }
|
|
53
|
+
.nav-links a { text-decoration: none; color: var(--ink-soft); font-size: 14px; }
|
|
54
|
+
.chaos-badge { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; background: var(--warn-soft); color: var(--warn); padding: 4px 8px; border-radius: 5px; }
|
|
55
|
+
.cart-link { text-decoration: none; color: var(--ink); display: flex; align-items: center; gap: 6px; }
|
|
56
|
+
.badge { background: var(--accent); color: white; border-radius: 999px; font-size: 12px; padding: 1px 7px; }
|
|
57
|
+
.theme-toggle { border: 1px solid var(--line); background: var(--paper); border-radius: 6px; padding: 6px 10px; cursor: pointer; }
|
|
58
|
+
.account-menu { display: flex; gap: 14px; align-items: center; }
|
|
59
|
+
.account-greeting { color: var(--ink); font-size: 14px; }
|
|
60
|
+
.account-menu a, .link-button { text-decoration: none; color: var(--ink-soft); background: none; border: none; padding: 0; cursor: pointer; font-size: 14px; }
|
|
61
|
+
.account-menu a:hover, .link-button:hover { color: var(--ink); text-decoration: underline; }
|
|
62
|
+
|
|
63
|
+
.toast-stack { position: fixed; top: 16px; right: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 100; }
|
|
64
|
+
.toast { background: var(--ink); color: var(--paper); padding: 10px 16px; border-radius: 8px; font-size: 14px; }
|
|
65
|
+
.toast-error { background: var(--danger); }
|
|
66
|
+
|
|
67
|
+
.page-container { max-width: 1080px; margin: 0 auto; padding: 28px 24px 64px; }
|
|
68
|
+
|
|
69
|
+
/* ---- buttons / forms ---- */
|
|
70
|
+
.btn { display: inline-block; padding: 9px 16px; border-radius: 6px; border: 1px solid transparent; cursor: pointer; font-size: 14px; text-decoration: none; }
|
|
71
|
+
.btn-primary { background: var(--accent); color: white; }
|
|
72
|
+
.btn-primary[aria-disabled='true'] { background: var(--line); color: var(--ink-soft); cursor: not-allowed; }
|
|
73
|
+
.btn-secondary { background: var(--paper-raised); border-color: var(--line); color: var(--ink); }
|
|
74
|
+
.btn-danger { background: var(--danger); color: white; }
|
|
75
|
+
.icon-btn { background: none; border: none; font-size: 16px; cursor: pointer; }
|
|
76
|
+
.inline-form { display: inline-block; margin-left: 8px; }
|
|
77
|
+
|
|
78
|
+
form label { display: block; margin: 12px 0 4px; font-size: 13px; color: var(--ink-soft); }
|
|
79
|
+
input[type='text'], input[type='email'], input[type='password'], input[type='search'], input[type='number'] {
|
|
80
|
+
padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--paper-raised); color: var(--ink); width: 100%; max-width: 320px;
|
|
81
|
+
}
|
|
82
|
+
.checkbox-row { display: flex; gap: 8px; align-items: center; font-size: 14px; margin: 6px 0; }
|
|
83
|
+
.form-error { color: var(--danger); }
|
|
84
|
+
.auth-form-wrap { max-width: 380px; }
|
|
85
|
+
|
|
86
|
+
/* ---- hero / sections ---- */
|
|
87
|
+
.hero { padding: 40px 0; border-bottom: 1px solid var(--line); margin-bottom: 32px; }
|
|
88
|
+
.hero h1 { max-width: 20ch; }
|
|
89
|
+
.section { margin-bottom: 40px; }
|
|
90
|
+
|
|
91
|
+
/* ---- product grid / cards ---- */
|
|
92
|
+
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 18px; }
|
|
93
|
+
.product-card { background: var(--paper-raised); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
|
|
94
|
+
.product-thumb { width: 100%; border-radius: 6px; margin-bottom: 8px; }
|
|
95
|
+
.product-card h3 { font-size: 15px; margin: 6px 0; }
|
|
96
|
+
.product-card h3 a { text-decoration: none; color: var(--ink); }
|
|
97
|
+
.category-tag { font-size: 12px; color: var(--ink-soft); }
|
|
98
|
+
.price { font-weight: 600; }
|
|
99
|
+
.badge-warn { background: var(--warn-soft); color: var(--warn); padding: 2px 8px; border-radius: 5px; font-size: 12px; }
|
|
100
|
+
|
|
101
|
+
/* ---- catalog layout ---- */
|
|
102
|
+
.catalog-layout { display: grid; grid-template-columns: 200px 1fr; gap: 28px; }
|
|
103
|
+
.filters h2 { font-size: 13px; text-transform: uppercase; color: var(--ink-soft); margin: 18px 0 8px; }
|
|
104
|
+
.pagination { display: flex; gap: 6px; margin-top: 20px; }
|
|
105
|
+
.page-link { padding: 6px 11px; border: 1px solid var(--line); border-radius: 6px; text-decoration: none; color: var(--ink); font-size: 13px; }
|
|
106
|
+
.page-link.active { background: var(--accent); color: white; border-color: var(--accent); }
|
|
107
|
+
|
|
108
|
+
/* ---- product detail ---- */
|
|
109
|
+
.product-detail { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin-bottom: 24px; }
|
|
110
|
+
.gallery-main { width: 100%; border-radius: 10px; }
|
|
111
|
+
.gallery-thumbs { display: flex; gap: 8px; margin-top: 8px; }
|
|
112
|
+
.thumb-btn { padding: 5px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--paper-raised); cursor: pointer; font-size: 12px; }
|
|
113
|
+
.qty-stepper { display: flex; align-items: center; gap: 8px; margin: 14px 0; }
|
|
114
|
+
.qty-stepper input { width: 60px; text-align: center; }
|
|
115
|
+
.qty-stepper button { width: 32px; height: 32px; border-radius: 6px; border: 1px solid var(--line); background: var(--paper-raised); cursor: pointer; }
|
|
116
|
+
|
|
117
|
+
.tabs [role='tablist'] { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 16px; }
|
|
118
|
+
.tabs [role='tab'] { padding: 10px 16px; border: none; background: none; cursor: pointer; color: var(--ink-soft); border-bottom: 2px solid transparent; }
|
|
119
|
+
.tabs [role='tab'][aria-selected='true'] { color: var(--ink); border-color: var(--accent); font-weight: 600; }
|
|
120
|
+
.review-list { list-style: none; padding: 0; }
|
|
121
|
+
.review-item { border-bottom: 1px solid var(--line); padding: 10px 0; }
|
|
122
|
+
|
|
123
|
+
/* ---- accordion ---- */
|
|
124
|
+
.accordion-item { border-bottom: 1px solid var(--line); }
|
|
125
|
+
.accordion-trigger { width: 100%; text-align: left; padding: 14px 0; background: none; border: none; font-size: 15px; cursor: pointer; color: var(--ink); }
|
|
126
|
+
.accordion-panel { padding-bottom: 14px; color: var(--ink-soft); }
|
|
127
|
+
|
|
128
|
+
/* ---- cart / checkout ---- */
|
|
129
|
+
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
|
|
130
|
+
.cart-table th, .cart-table td, table td, table th { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
|
|
131
|
+
.qty-edit { width: 60px; }
|
|
132
|
+
.cart-total { font-size: 16px; margin: 16px 0; }
|
|
133
|
+
.checkout-step { margin-bottom: 28px; padding-bottom: 20px; border-bottom: 1px solid var(--line); }
|
|
134
|
+
.payment-frame, .payment-frame-small { width: 100%; max-width: 420px; height: 180px; border: 1px solid var(--line); border-radius: 8px; }
|
|
135
|
+
.payment-frame-small { height: 140px; }
|
|
136
|
+
.checkout-summary { list-style: none; padding: 0; }
|
|
137
|
+
.autocomplete { position: relative; max-width: 320px; }
|
|
138
|
+
.autocomplete-list { position: absolute; z-index: 5; background: var(--paper-raised); border: 1px solid var(--line); border-radius: 6px; list-style: none; margin: 4px 0 0; padding: 4px; width: 100%; }
|
|
139
|
+
.autocomplete-list li { padding: 7px 10px; cursor: pointer; border-radius: 4px; }
|
|
140
|
+
.autocomplete-list li:hover { background: var(--accent-soft); }
|
|
141
|
+
|
|
142
|
+
.frame-body { padding: 18px; font-family: system-ui, sans-serif; background: var(--paper); color: var(--ink); }
|
|
143
|
+
.payment-form .form-row { display: flex; gap: 12px; }
|
|
144
|
+
.hint-text { font-size: 13px; color: var(--ink-soft); }
|
|
145
|
+
|
|
146
|
+
/* ---- account / wishlist ---- */
|
|
147
|
+
.wishlist { list-style: none; padding: 0; max-width: 420px; }
|
|
148
|
+
.wishlist-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--paper-raised); border: 1px solid var(--line); border-radius: 8px; margin-bottom: 8px; cursor: grab; }
|
|
149
|
+
.wishlist-item.dragging { opacity: 0.4; }
|
|
150
|
+
.drag-handle { color: var(--ink-soft); }
|
|
151
|
+
.wishlist-empty { color: var(--ink-soft); border: none; background: none; }
|
|
152
|
+
.upload-form { display: flex; align-items: center; gap: 10px; }
|
|
153
|
+
|
|
154
|
+
/* ---- locators playground ---- */
|
|
155
|
+
.locator-list { display: flex; flex-direction: column; gap: 22px; }
|
|
156
|
+
.locator-demo { background: var(--paper-raised); border: 1px solid var(--line); border-radius: 10px; padding: 18px 20px; }
|
|
157
|
+
.locator-demo h3 { margin-top: 0; font-size: 15px; }
|
|
158
|
+
.reveal-btn { margin-top: 10px; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--paper); cursor: pointer; font-size: 13px; }
|
|
159
|
+
.locator-demo .answer { margin-top: 10px; padding: 10px 12px; font-size: 13px; white-space: pre-wrap; }
|
|
160
|
+
.locator-demo .answer:not([hidden]) { display: block; }
|
|
161
|
+
.demo-dialog { border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; background: var(--paper); max-width: 360px; }
|
|
162
|
+
.demo-status { display: block; font-size: 13px; color: var(--accent); }
|
|
163
|
+
|
|
164
|
+
.site-footer { text-align: center; padding: 20px; color: var(--ink-soft); font-size: 13px; border-top: 1px solid var(--line); }
|
|
165
|
+
|
|
166
|
+
@media (max-width: 720px) {
|
|
167
|
+
.catalog-layout, .product-detail { grid-template-columns: 1fr; }
|
|
168
|
+
.navbar { flex-wrap: wrap; }
|
|
169
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2
|
+
document.querySelectorAll('.accordion-trigger').forEach((btn) => {
|
|
3
|
+
btn.addEventListener('click', () => {
|
|
4
|
+
const panel = document.getElementById(btn.getAttribute('aria-controls'));
|
|
5
|
+
const open = btn.getAttribute('aria-expanded') === 'true';
|
|
6
|
+
btn.setAttribute('aria-expanded', String(!open));
|
|
7
|
+
panel.hidden = open;
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2
|
+
const input = document.getElementById('address-input');
|
|
3
|
+
const list = document.getElementById('address-listbox');
|
|
4
|
+
if (!input || !list) return;
|
|
5
|
+
|
|
6
|
+
const sampleAddresses = [
|
|
7
|
+
'12 Sukhumvit Road', '45 Silom Soi 3', '88 Rama IX Avenue',
|
|
8
|
+
'210 Ratchadaphisek Road', '5 Ekkamai Soi 10', '77 Ladprao Road',
|
|
9
|
+
'19 Phahonyothin Road', '3 Sathorn Soi 1',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
input.addEventListener('input', () => {
|
|
13
|
+
const q = input.value.trim().toLowerCase();
|
|
14
|
+
list.innerHTML = '';
|
|
15
|
+
if (!q) {
|
|
16
|
+
list.hidden = true;
|
|
17
|
+
input.setAttribute('aria-expanded', 'false');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const matches = sampleAddresses.filter((a) => a.toLowerCase().includes(q)).slice(0, 5);
|
|
21
|
+
matches.forEach((addr) => {
|
|
22
|
+
const li = document.createElement('li');
|
|
23
|
+
li.setAttribute('role', 'option');
|
|
24
|
+
li.textContent = addr;
|
|
25
|
+
li.addEventListener('click', () => {
|
|
26
|
+
input.value = addr;
|
|
27
|
+
list.hidden = true;
|
|
28
|
+
input.setAttribute('aria-expanded', 'false');
|
|
29
|
+
});
|
|
30
|
+
list.appendChild(li);
|
|
31
|
+
});
|
|
32
|
+
list.hidden = matches.length === 0;
|
|
33
|
+
input.setAttribute('aria-expanded', String(matches.length > 0));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
document.addEventListener('click', (e) => {
|
|
37
|
+
if (!input.contains(e.target) && !list.contains(e.target)) list.hidden = true;
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
function updateCartBadge(count) {
|
|
2
|
+
const badge = document.querySelector('.cart-link .badge');
|
|
3
|
+
if (badge) badge.textContent = count;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
7
|
+
const qtyInput = document.getElementById('qty-input');
|
|
8
|
+
const qtyMinus = document.getElementById('qty-minus');
|
|
9
|
+
const qtyPlus = document.getElementById('qty-plus');
|
|
10
|
+
if (qtyInput && qtyMinus && qtyPlus) {
|
|
11
|
+
qtyMinus.addEventListener('click', () => {
|
|
12
|
+
qtyInput.value = Math.max(1, Number(qtyInput.value) - 1);
|
|
13
|
+
});
|
|
14
|
+
qtyPlus.addEventListener('click', () => {
|
|
15
|
+
qtyInput.value = Number(qtyInput.value) + 1;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
document.querySelectorAll('[data-role="add-to-cart"]').forEach((btn) => {
|
|
20
|
+
btn.addEventListener('click', async () => {
|
|
21
|
+
const qtyInputId = btn.dataset.qtyInput;
|
|
22
|
+
const qty = qtyInputId ? Number(document.getElementById(qtyInputId).value) || 1 : 1;
|
|
23
|
+
const res = await fetch('/api/cart', {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
body: JSON.stringify({ productId: btn.dataset.productId, qty }),
|
|
27
|
+
});
|
|
28
|
+
const cart = await res.json();
|
|
29
|
+
updateCartBadge(cart.count);
|
|
30
|
+
window.toast(`${btn.dataset.productName || 'Item'} added to cart`);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
document.querySelectorAll('.qty-edit').forEach((input) => {
|
|
35
|
+
input.addEventListener('change', async () => {
|
|
36
|
+
await fetch(`/api/cart/${input.dataset.productId}`, {
|
|
37
|
+
method: 'PATCH',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({ qty: input.value }),
|
|
40
|
+
});
|
|
41
|
+
window.location.reload();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
document.querySelectorAll('.remove-line').forEach((btn) => {
|
|
46
|
+
btn.addEventListener('click', async () => {
|
|
47
|
+
const cart = await (await fetch(`/api/cart/${btn.dataset.productId}`, { method: 'DELETE' })).json();
|
|
48
|
+
updateCartBadge(cart.count);
|
|
49
|
+
window.location.reload();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2
|
+
const form = document.getElementById('delete-account-form');
|
|
3
|
+
if (!form) return;
|
|
4
|
+
form.addEventListener('submit', (e) => {
|
|
5
|
+
const ok = window.confirm('Delete this account? This cannot be undone.');
|
|
6
|
+
if (!ok) e.preventDefault();
|
|
7
|
+
});
|
|
8
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2
|
+
const list = document.getElementById('wishlist-list');
|
|
3
|
+
if (!list) return;
|
|
4
|
+
|
|
5
|
+
let dragged = null;
|
|
6
|
+
|
|
7
|
+
list.querySelectorAll('.wishlist-item').forEach((item) => {
|
|
8
|
+
item.addEventListener('dragstart', () => {
|
|
9
|
+
dragged = item;
|
|
10
|
+
item.classList.add('dragging');
|
|
11
|
+
});
|
|
12
|
+
item.addEventListener('dragend', () => item.classList.remove('dragging'));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
list.addEventListener('dragover', (e) => {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
const after = getItemAfter(list, e.clientY);
|
|
18
|
+
if (!dragged) return;
|
|
19
|
+
if (after == null) list.appendChild(dragged);
|
|
20
|
+
else list.insertBefore(dragged, after);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
list.addEventListener('drop', () => {
|
|
24
|
+
const order = Array.from(list.querySelectorAll('.wishlist-item')).map((el) => el.dataset.productId);
|
|
25
|
+
fetch('/wishlist', {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
28
|
+
body: 'order=' + order.join(','),
|
|
29
|
+
}).then(() => window.toast && window.toast('Wishlist order saved'));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function getItemAfter(container, y) {
|
|
33
|
+
const items = [...container.querySelectorAll('.wishlist-item:not(.dragging)')];
|
|
34
|
+
return items.reduce(
|
|
35
|
+
(closest, child) => {
|
|
36
|
+
const box = child.getBoundingClientRect();
|
|
37
|
+
const offset = y - box.top - box.height / 2;
|
|
38
|
+
if (offset < 0 && offset > closest.offset) return { offset, element: child };
|
|
39
|
+
return closest;
|
|
40
|
+
},
|
|
41
|
+
{ offset: Number.NEGATIVE_INFINITY, element: null }
|
|
42
|
+
).element;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2
|
+
document.querySelectorAll('.reveal-btn').forEach((btn) => {
|
|
3
|
+
btn.addEventListener('click', () => {
|
|
4
|
+
const answer = btn.nextElementSibling;
|
|
5
|
+
if (!answer) return;
|
|
6
|
+
answer.hidden = !answer.hidden;
|
|
7
|
+
btn.textContent = answer.hidden ? 'Reveal' : 'Hide';
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
class StarRating extends HTMLElement {
|
|
2
|
+
connectedCallback() {
|
|
3
|
+
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
|
|
4
|
+
this.render();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
static get observedAttributes() {
|
|
8
|
+
return ['value'];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
attributeChangedCallback() {
|
|
12
|
+
if (this.shadowRoot) this.render();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
render() {
|
|
16
|
+
const value = Number(this.getAttribute('value')) || 0;
|
|
17
|
+
const root = this.shadowRoot;
|
|
18
|
+
root.innerHTML = `
|
|
19
|
+
<style>
|
|
20
|
+
.stars { display: inline-flex; gap: 2px; font-size: 20px; cursor: pointer; }
|
|
21
|
+
.star { color: #c7cdc9; }
|
|
22
|
+
.star.filled { color: #d7ac5c; }
|
|
23
|
+
</style>
|
|
24
|
+
<span class="stars"></span>
|
|
25
|
+
`;
|
|
26
|
+
const wrap = root.querySelector('.stars');
|
|
27
|
+
for (let i = 1; i <= 5; i++) {
|
|
28
|
+
const star = document.createElement('span');
|
|
29
|
+
star.className = 'star' + (i <= value ? ' filled' : '');
|
|
30
|
+
star.dataset.star = String(i);
|
|
31
|
+
star.textContent = '★';
|
|
32
|
+
star.addEventListener('click', () => this.setAttribute('value', String(i)));
|
|
33
|
+
wrap.appendChild(star);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
customElements.define('star-rating', StarRating);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2
|
+
document.querySelectorAll('[role="tablist"]').forEach((tablist) => {
|
|
3
|
+
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
|
|
4
|
+
tabs.forEach((tab) => {
|
|
5
|
+
tab.addEventListener('click', () => {
|
|
6
|
+
tabs.forEach((t) => {
|
|
7
|
+
t.setAttribute('aria-selected', String(t === tab));
|
|
8
|
+
const panel = document.getElementById(t.getAttribute('aria-controls'));
|
|
9
|
+
if (panel) panel.hidden = t !== tab;
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2
|
+
const btn = document.getElementById('theme-toggle');
|
|
3
|
+
if (!btn) return;
|
|
4
|
+
const html = document.documentElement;
|
|
5
|
+
|
|
6
|
+
const saved = (() => {
|
|
7
|
+
try {
|
|
8
|
+
return localStorage.getItem('qa-lab-theme');
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
})();
|
|
13
|
+
if (saved) html.setAttribute('data-theme', saved);
|
|
14
|
+
|
|
15
|
+
btn.addEventListener('click', () => {
|
|
16
|
+
const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
|
17
|
+
html.setAttribute('data-theme', next);
|
|
18
|
+
try {
|
|
19
|
+
localStorage.setItem('qa-lab-theme', next);
|
|
20
|
+
} catch {
|
|
21
|
+
/* private browsing — theme just won't persist */
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
window.toast = function toast(message, type) {
|
|
2
|
+
const stack = document.getElementById('toast-stack');
|
|
3
|
+
if (!stack) return;
|
|
4
|
+
const el = document.createElement('div');
|
|
5
|
+
el.className = 'toast' + (type === 'error' ? ' toast-error' : '');
|
|
6
|
+
el.setAttribute('role', 'status');
|
|
7
|
+
el.textContent = message;
|
|
8
|
+
stack.appendChild(el);
|
|
9
|
+
setTimeout(() => el.remove(), 3500);
|
|
10
|
+
};
|
|
Binary file
|
package/src/chaos.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const state = require('./state');
|
|
2
|
+
|
|
3
|
+
// Mild chaos: CSS class names drift, but data-testid stays put (still locatable).
|
|
4
|
+
// Aggressive chaos: both class names AND data-testid values drift — forces a
|
|
5
|
+
// self-healing agent to fall back to role/text/label locators instead.
|
|
6
|
+
function cx(base) {
|
|
7
|
+
const mode = state.getChaosMode();
|
|
8
|
+
if (mode === 'off') return base;
|
|
9
|
+
return `${base}-${state.getChaosSeed()}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function testId(base) {
|
|
13
|
+
const mode = state.getChaosMode();
|
|
14
|
+
if (mode !== 'aggressive') return base;
|
|
15
|
+
return `${base}-${state.getChaosSeed()}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { cx, testId };
|