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.
Files changed (62) hide show
  1. package/README.md +69 -0
  2. package/bin/cli.js +11 -0
  3. package/image/1TB External SSD.jpg +0 -0
  4. package/image/Bluetooth Speaker.jpg +0 -0
  5. package/image/Ethernet Cable 3m.jpg +0 -0
  6. package/image/Laptop Stand.jpg +0 -0
  7. package/image/Mechanical Keyboard.jpg +0 -0
  8. package/image/Noise Cancelling Headphones.jpg +0 -0
  9. package/image/USB-C Hub.jpg +0 -0
  10. package/image/Webcam 1080p.jpg +0 -0
  11. package/image/Wi-Fi 6 Router.jpg +0 -0
  12. package/image/wireless mouse.jpg +0 -0
  13. package/mouse.jpg +0 -0
  14. package/package.json +26 -0
  15. package/public/css/style.css +169 -0
  16. package/public/images/products/1.jpg +0 -0
  17. package/public/images/products/10.jpg +0 -0
  18. package/public/images/products/2.jpg +0 -0
  19. package/public/images/products/3.jpg +0 -0
  20. package/public/images/products/4.jpg +0 -0
  21. package/public/images/products/5.jpg +0 -0
  22. package/public/images/products/6.jpg +0 -0
  23. package/public/images/products/7.jpg +0 -0
  24. package/public/images/products/8.jpg +0 -0
  25. package/public/images/products/9.jpg +0 -0
  26. package/public/js/accordion.js +10 -0
  27. package/public/js/autocomplete.js +39 -0
  28. package/public/js/cart.js +52 -0
  29. package/public/js/confirm-dialog.js +8 -0
  30. package/public/js/drag-drop.js +44 -0
  31. package/public/js/reveal-answer.js +10 -0
  32. package/public/js/star-rating.js +38 -0
  33. package/public/js/tabs.js +14 -0
  34. package/public/js/theme-toggle.js +24 -0
  35. package/public/js/toast.js +10 -0
  36. package/qa-copilot-lab-0.1.0.tgz +0 -0
  37. package/src/chaos.js +18 -0
  38. package/src/db.js +97 -0
  39. package/src/middleware/session.js +16 -0
  40. package/src/routes/api.js +136 -0
  41. package/src/routes/auth.js +45 -0
  42. package/src/routes/devApi.js +56 -0
  43. package/src/routes/pages.js +125 -0
  44. package/src/seed.js +43 -0
  45. package/src/server.js +71 -0
  46. package/src/state.js +33 -0
  47. package/views/404.ejs +7 -0
  48. package/views/account.ejs +42 -0
  49. package/views/cart.ejs +42 -0
  50. package/views/catalog.ejs +69 -0
  51. package/views/checkout.ejs +49 -0
  52. package/views/home.ejs +42 -0
  53. package/views/invoice.ejs +18 -0
  54. package/views/locators.ejs +323 -0
  55. package/views/login.ejs +22 -0
  56. package/views/orders.ejs +33 -0
  57. package/views/partials/footer.ejs +18 -0
  58. package/views/partials/head.ejs +9 -0
  59. package/views/partials/header.ejs +39 -0
  60. package/views/payment-frame.ejs +26 -0
  61. package/views/product.ejs +77 -0
  62. package/views/register.ejs +25 -0
package/views/cart.ejs ADDED
@@ -0,0 +1,42 @@
1
+ <%- include('partials/head', { title: 'Cart' }) %>
2
+ <%- include('partials/header') %>
3
+
4
+ <h1>Your cart</h1>
5
+
6
+ <% if (cart.items.length === 0) { %>
7
+ <p>Your cart is empty. <a href="/catalog">Go find something to add.</a></p>
8
+ <% } else { %>
9
+ <table class="cart-table" data-testid="<%= tid('cart-table') %>">
10
+ <thead>
11
+ <tr><th>Item</th><th>Price</th><th>Qty</th><th>Subtotal</th><th></th></tr>
12
+ </thead>
13
+ <tbody>
14
+ <% cart.items.forEach(function(line) { %>
15
+ <tr data-testid="<%= tid('cart-row-' + line.productId) %>">
16
+ <td><%= line.name %></td>
17
+ <td><%= line.price.toFixed(2) %></td>
18
+ <td>
19
+ <input type="number" min="1" value="<%= line.qty %>" class="qty-edit" data-product-id="<%= line.productId %>" aria-label="Quantity for <%= line.name %>">
20
+ </td>
21
+ <td><%= (line.price * line.qty).toFixed(2) %></td>
22
+ <td>
23
+ <button type="button" class="icon-btn remove-line" title="Remove item" data-product-id="<%= line.productId %>" aria-label="Remove <%= line.name %> from cart">
24
+ ๐Ÿ—‘
25
+ </button>
26
+ </td>
27
+ </tr>
28
+ <% }); %>
29
+ </tbody>
30
+ </table>
31
+
32
+ <p class="cart-total">Total: <strong data-testid="<%= tid('cart-total') %>"><%= cart.items.reduce((s,l) => s + l.price*l.qty, 0).toFixed(2) %> USD</strong></p>
33
+
34
+ <a href="/checkout"
35
+ class="btn btn-primary"
36
+ data-testid="<%= tid('checkout-button') %>"
37
+ <%= (bugSet === 1 || cart.items.length === 0) ? 'aria-disabled="true" onclick="return false;" tabindex="-1"' : '' %>>
38
+ Proceed to checkout
39
+ </a>
40
+ <% } %>
41
+
42
+ <%- include('partials/footer') %>
@@ -0,0 +1,69 @@
1
+ <%- include('partials/head', { title: 'Catalog' }) %>
2
+ <%- include('partials/header') %>
3
+
4
+ <div class="catalog-layout">
5
+ <aside class="filters" aria-label="Filters">
6
+ <h2>Category</h2>
7
+ <form method="get" action="/catalog" class="filter-form">
8
+ <% categories.forEach(function(c) { %>
9
+ <label class="checkbox-row">
10
+ <input type="radio" name="category" value="<%= c %>" <%= category === c ? 'checked' : '' %> onchange="this.form.submit()">
11
+ <%= c %>
12
+ </label>
13
+ <% }); %>
14
+ <label class="checkbox-row">
15
+ <input type="radio" name="category" value="" <%= category === '' ? 'checked' : '' %> onchange="this.form.submit()">
16
+ All categories
17
+ </label>
18
+
19
+ <h2>Max price</h2>
20
+ <label for="maxPrice">Up to $<span id="maxPriceValue"><%= maxPrice || 150 %></span></label>
21
+ <input type="range" id="maxPrice" name="maxPrice" min="5" max="150" value="<%= maxPrice || 150 %>"
22
+ oninput="document.getElementById('maxPriceValue').textContent = this.value"
23
+ onchange="this.form.submit()">
24
+ </form>
25
+ </aside>
26
+
27
+ <section class="catalog-results">
28
+ <h1><%= category || 'All products' %><% if (q) { %> โ€” matching "<%= q %>"<% } %></h1>
29
+
30
+ <div class="product-grid" data-testid="<%= tid('catalog-grid') %>">
31
+ <% products.forEach(function(p) { %>
32
+ <article class="product-card" data-testid="<%= tid('product-card-' + p.id) %>">
33
+ <img src="/api/img/<%= p.id %>" alt="<%= p.name %> product photo" class="product-thumb">
34
+ <h3><a href="/product/<%= p.id %>"><%= p.name %></a></h3>
35
+ <p class="category-tag"><%= p.category %></p>
36
+ <p class="price"><%= p.price.toFixed(2) %> USD</p>
37
+ <% if (p.stock === 0) { %>
38
+ <span class="badge badge-warn">Out of stock</span>
39
+ <% } else { %>
40
+ <button type="button"
41
+ class="btn btn-primary <%= cx('add-to-cart') %>"
42
+ data-role="add-to-cart"
43
+ data-testid="<%= tid('add-to-cart-' + p.id) %>"
44
+ data-product-id="<%= p.id %>"
45
+ data-product-name="<%= p.name %>">
46
+ Add to cart
47
+ </button>
48
+ <% } %>
49
+ </article>
50
+ <% }); %>
51
+ </div>
52
+
53
+ <% if (products.length === 0) { %>
54
+ <p>No products match those filters.</p>
55
+ <% } %>
56
+
57
+ <nav class="pagination" aria-label="Catalog pages">
58
+ <% for (let i = 1; i <= totalPages; i++) { %>
59
+ <a href="/catalog?category=<%= category %>&maxPrice=<%= maxPrice || '' %>&page=<%= i %>"
60
+ class="<%= i === page ? 'page-link active' : 'page-link' %>"
61
+ aria-current="<%= i === page ? 'page' : 'false' %>">
62
+ <%= i %>
63
+ </a>
64
+ <% } %>
65
+ </nav>
66
+ </section>
67
+ </div>
68
+
69
+ <%- include('partials/footer') %>
@@ -0,0 +1,49 @@
1
+ <%- include('partials/head', { title: 'Checkout' }) %>
2
+ <%- include('partials/header') %>
3
+
4
+ <h1>Checkout</h1>
5
+
6
+ <section class="checkout-step">
7
+ <h2>1. Shipping address</h2>
8
+ <div class="autocomplete" data-component="autocomplete">
9
+ <label for="address-input">Street address</label>
10
+ <input type="text" id="address-input" placeholder="Start typing an addressโ€ฆ" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="address-listbox">
11
+ <ul id="address-listbox" role="listbox" class="autocomplete-list" hidden></ul>
12
+ </div>
13
+ <label for="city-input">City</label>
14
+ <input type="text" id="city-input" name="city">
15
+ </section>
16
+
17
+ <section class="checkout-step">
18
+ <h2>2. Payment</h2>
19
+ <iframe src="/payment-frame" title="Payment details" id="payment-frame" class="payment-frame"></iframe>
20
+ </section>
21
+
22
+ <section class="checkout-step">
23
+ <h2>3. Review</h2>
24
+ <ul class="checkout-summary">
25
+ <% cart.items.forEach(function(line) { %>
26
+ <li><%= line.name %> ร— <%= line.qty %> โ€” <%= (line.price * line.qty).toFixed(2) %> USD</li>
27
+ <% }); %>
28
+ </ul>
29
+ <p class="cart-total">Total: <strong><%= cart.items.reduce((s,l) => s + l.price*l.qty, 0).toFixed(2) %> USD</strong></p>
30
+
31
+ <button type="button" class="btn btn-primary" id="place-order-btn" data-testid="<%= tid('place-order-button') %>">
32
+ Place order
33
+ </button>
34
+ </section>
35
+
36
+ <script>
37
+ document.getElementById('place-order-btn').addEventListener('click', async () => {
38
+ const res = await fetch('/api/orders', { method: 'POST' });
39
+ if (res.ok) {
40
+ const order = await res.json();
41
+ window.location.href = '/account/orders?placed=' + order.id;
42
+ } else {
43
+ const err = await res.json();
44
+ window.toast(err.error || 'Could not place order', 'error');
45
+ }
46
+ });
47
+ </script>
48
+
49
+ <%- include('partials/footer') %>
package/views/home.ejs ADDED
@@ -0,0 +1,42 @@
1
+ <%- include('partials/head', { title: 'Home' }) %>
2
+ <%- include('partials/header') %>
3
+
4
+ <section class="hero">
5
+ <h1>A store to practice on, not a store to shop at</h1>
6
+ <p>Every button, table, and form here exists to give a Playwright locator โ€” or an AI agent driving one โ€” something realistic to grab onto.</p>
7
+ <a href="/catalog" class="btn btn-primary" data-testid="<%= tid('hero-shop-cta') %>">Browse catalog</a>
8
+ </section>
9
+
10
+ <section class="section">
11
+ <h2>Featured products</h2>
12
+ <div class="product-grid">
13
+ <% featured.forEach(function(p) { %>
14
+ <article class="product-card" data-testid="<%= tid('product-card-' + p.id) %>">
15
+ <img src="/api/img/<%= p.id %>" alt="<%= p.name %> product photo" class="product-thumb">
16
+ <h3><a href="/product/<%= p.id %>"><%= p.name %></a></h3>
17
+ <p class="price"><%= p.price.toFixed(2) %> USD</p>
18
+ <% if (p.stock === 0) { %>
19
+ <span class="badge badge-warn"><%= 'Out of stock' %></span>
20
+ <% } %>
21
+ </article>
22
+ <% }); %>
23
+ </div>
24
+ </section>
25
+
26
+ <section class="section">
27
+ <h2>Frequently asked questions</h2>
28
+ <div class="accordion" data-component="accordion">
29
+ <% faqs.forEach(function(f, i) { %>
30
+ <div class="accordion-item">
31
+ <button type="button" class="accordion-trigger" aria-expanded="false" aria-controls="faq-panel-<%= i %>" data-testid="<%= tid('faq-trigger-' + i) %>">
32
+ <%= f.question %>
33
+ </button>
34
+ <div class="accordion-panel" id="faq-panel-<%= i %>" hidden>
35
+ <p><%= f.answer %></p>
36
+ </div>
37
+ </div>
38
+ <% }); %>
39
+ </div>
40
+ </section>
41
+
42
+ <%- include('partials/footer') %>
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Invoice <%= order.id %></title>
6
+ <link rel="stylesheet" href="/css/style.css">
7
+ </head>
8
+ <body class="frame-body">
9
+ <h1>Invoice <%= order.id %></h1>
10
+ <p>Date: <%= order.created_at %></p>
11
+ <p>Billed to: <%= order.username %></p>
12
+ <table>
13
+ <tr><td><b>Item</b></td><td><b>Qty</b></td><td><b>Total</b></td></tr>
14
+ <tr><td><%= order.item %></td><td><%= order.qty %></td><td><%= order.total.toFixed(2) %> USD</td></tr>
15
+ </table>
16
+ <p>Status: <%= order.status %></p>
17
+ </body>
18
+ </html>
@@ -0,0 +1,323 @@
1
+ <%- include('partials/head', { title: 'Locator Playground' }) %>
2
+ <%- include('partials/header') %>
3
+
4
+ <h1>Locator Playground</h1>
5
+ <p>Thirty-six locator strategies, each isolated below. Try writing the Playwright locator yourself, then press "Reveal" to check.</p>
6
+
7
+ <div class="locator-list">
8
+
9
+ <section class="locator-demo">
10
+ <h3>1. Role + accessible name</h3>
11
+ <button type="button" class="btn btn-primary">Add to cart</button>
12
+ <button type="button" class="btn btn-primary">Add to cart</button>
13
+ <p class="hint-text">Two buttons, identical text โ€” on the real catalog page you would scope from the parent card first.</p>
14
+ <button type="button" class="reveal-btn">Reveal</button>
15
+ <code class="answer" hidden>page.getByRole('button', { name: 'Add to cart' }).first()</code>
16
+ </section>
17
+
18
+ <section class="locator-demo">
19
+ <h3>2. Label</h3>
20
+ <label for="demo-email">Email</label>
21
+ <input type="email" id="demo-email">
22
+ <button type="button" class="reveal-btn">Reveal</button>
23
+ <code class="answer" hidden>page.getByLabel('Email')</code>
24
+ </section>
25
+
26
+ <section class="locator-demo">
27
+ <h3>3. Placeholder</h3>
28
+ <input type="search" placeholder="Search productsโ€ฆ">
29
+ <button type="button" class="reveal-btn">Reveal</button>
30
+ <code class="answer" hidden>page.getByPlaceholder('Search productsโ€ฆ')</code>
31
+ </section>
32
+
33
+ <section class="locator-demo">
34
+ <h3>4. Alt text</h3>
35
+ <img src="/api/img/1" alt="Wireless Mouse product photo" width="80">
36
+ <button type="button" class="reveal-btn">Reveal</button>
37
+ <code class="answer" hidden>page.getByAltText('Wireless Mouse product photo')</code>
38
+ </section>
39
+
40
+ <section class="locator-demo">
41
+ <h3>5. Title / tooltip</h3>
42
+ <button type="button" class="icon-btn" title="Remove item" aria-label="Remove item">๐Ÿ—‘</button>
43
+ <button type="button" class="reveal-btn">Reveal</button>
44
+ <code class="answer" hidden>page.getByTitle('Remove item')</code>
45
+ </section>
46
+
47
+ <section class="locator-demo">
48
+ <h3>6. Test ID</h3>
49
+ <div data-testid="demo-cart-item-42">Cart line 42</div>
50
+ <button type="button" class="reveal-btn">Reveal</button>
51
+ <code class="answer" hidden>page.getByTestId('demo-cart-item-42')</code>
52
+ </section>
53
+
54
+ <section class="locator-demo">
55
+ <h3>7. Text โ€” exact vs substring</h3>
56
+ <span class="badge badge-warn">Out of stock</span>
57
+ <button type="button" class="reveal-btn">Reveal</button>
58
+ <code class="answer" hidden>page.getByText('Out of stock', { exact: true })</code>
59
+ </section>
60
+
61
+ <section class="locator-demo">
62
+ <h3>8. CSS attribute selector</h3>
63
+ <span data-price="19.99">19.99</span>
64
+ <button type="button" class="reveal-btn">Reveal</button>
65
+ <code class="answer" hidden>page.locator('[data-price]')</code>
66
+ </section>
67
+
68
+ <section class="locator-demo">
69
+ <h3>9. XPath axes (legacy markup)</h3>
70
+ <table>
71
+ <tr><td>SKU-1029</td><td>USB-C Hub</td><td>69.00</td></tr>
72
+ </table>
73
+ <button type="button" class="reveal-btn">Reveal</button>
74
+ <code class="answer" hidden>page.locator('xpath=//tr[td[text()="SKU-1029"]]/td[3]')</code>
75
+ </section>
76
+
77
+ <section class="locator-demo">
78
+ <h3>10. Chained + filter</h3>
79
+ <div class="product-card"><h4>Mechanical Keyboard</h4><button type="button">Add to cart</button></div>
80
+ <div class="product-card"><h4>Wireless Mouse</h4><button type="button">Add to cart</button></div>
81
+ <button type="button" class="reveal-btn">Reveal</button>
82
+ <code class="answer" hidden>page.locator('.product-card').filter({ hasText: 'Mechanical Keyboard' }).getByRole('button')</code>
83
+ </section>
84
+
85
+ <section class="locator-demo">
86
+ <h3>11. nth / first / last</h3>
87
+ <ul class="review-list">
88
+ <li class="review-item">Review 1</li>
89
+ <li class="review-item">Review 2</li>
90
+ <li class="review-item">Review 3</li>
91
+ </ul>
92
+ <button type="button" class="reveal-btn">Reveal</button>
93
+ <code class="answer" hidden>page.locator('.review-item').nth(2)</code>
94
+ </section>
95
+
96
+ <section class="locator-demo">
97
+ <h3>12. Frame locator</h3>
98
+ <iframe src="/payment-frame" title="Payment details" class="payment-frame-small"></iframe>
99
+ <button type="button" class="reveal-btn">Reveal</button>
100
+ <code class="answer" hidden>page.frameLocator('iframe[title="Payment details"]').getByLabel('Card number')</code>
101
+ </section>
102
+
103
+ <section class="locator-demo">
104
+ <h3>13. Shadow DOM piercing</h3>
105
+ <star-rating value="4"></star-rating>
106
+ <button type="button" class="reveal-btn">Reveal</button>
107
+ <code class="answer" hidden>page.locator('star-rating').locator('[data-star="4"]')</code>
108
+ </section>
109
+
110
+ <section class="locator-demo">
111
+ <h3>14. ARIA state</h3>
112
+ <button role="tab" aria-selected="true">Description</button>
113
+ <button role="tab" aria-selected="false">Reviews</button>
114
+ <button type="button" class="reveal-btn">Reveal</button>
115
+ <code class="answer" hidden>page.getByRole('tab', { selected: true })</code>
116
+ </section>
117
+
118
+ <section class="locator-demo">
119
+ <h3>15. Disabled state</h3>
120
+ <a class="btn btn-primary" aria-disabled="true" tabindex="-1">Proceed to checkout</a>
121
+ <button type="button" class="reveal-btn">Reveal</button>
122
+ <code class="answer" hidden>await expect(page.getByRole('link', { name: 'Proceed to checkout' })).toHaveAttribute('aria-disabled', 'true')</code>
123
+ </section>
124
+
125
+ <section class="locator-demo">
126
+ <h3>16. Native browser dialog</h3>
127
+ <button type="button" class="btn btn-danger" onclick="confirm('Delete this demo account?')">Delete account</button>
128
+ <button type="button" class="reveal-btn">Reveal</button>
129
+ <code class="answer" hidden>page.once('dialog', dialog => dialog.accept());</code>
130
+ </section>
131
+
132
+ </div>
133
+
134
+ <h2>ARIA roles for getByRole</h2>
135
+ <p class="hint-text">Twenty common ARIA roles, one demo each โ€” <code>getByRole</code> stays stable even when class names or DOM structure around these elements change.</p>
136
+
137
+ <div class="locator-list">
138
+
139
+ <section class="locator-demo">
140
+ <h3>17. Role: textbox</h3>
141
+ <label for="demo-address">Shipping address</label>
142
+ <input type="text" id="demo-address">
143
+ <button type="button" class="reveal-btn">Reveal</button>
144
+ <code class="answer" hidden>page.getByRole('textbox', { name: 'Shipping address' })</code>
145
+ </section>
146
+
147
+ <section class="locator-demo">
148
+ <h3>18. Role: checkbox</h3>
149
+ <label><input type="checkbox" id="demo-subscribe"> Subscribe to newsletter</label>
150
+ <button type="button" class="reveal-btn">Reveal</button>
151
+ <code class="answer" hidden>page.getByRole('checkbox', { name: 'Subscribe to newsletter' })</code>
152
+ </section>
153
+
154
+ <section class="locator-demo">
155
+ <h3>19. Role: combobox</h3>
156
+ <label for="demo-country">Country</label>
157
+ <select id="demo-country">
158
+ <option value="th">Thailand</option>
159
+ <option value="us">United States</option>
160
+ </select>
161
+ <button type="button" class="reveal-btn">Reveal</button>
162
+ <code class="answer" hidden>page.getByRole('combobox', { name: 'Country' })</code>
163
+ </section>
164
+
165
+ <section class="locator-demo">
166
+ <h3>20. Role: slider</h3>
167
+ <label for="demo-price-range">Max price</label>
168
+ <input type="range" id="demo-price-range" min="0" max="1000" value="500">
169
+ <button type="button" class="reveal-btn">Reveal</button>
170
+ <code class="answer" hidden>page.getByRole('slider', { name: 'Max price' })</code>
171
+ </section>
172
+
173
+ <section class="locator-demo">
174
+ <h3>21. Role: switch</h3>
175
+ <button type="button" role="switch" aria-checked="true">Dark mode</button>
176
+ <p class="hint-text">No native HTML element maps to <code>switch</code> โ€” it always needs an explicit <code>role</code> and <code>aria-checked</code>.</p>
177
+ <button type="button" class="reveal-btn">Reveal</button>
178
+ <code class="answer" hidden>page.getByRole('switch', { name: 'Dark mode' })</code>
179
+ </section>
180
+
181
+ <section class="locator-demo">
182
+ <h3>22. Role: navigation</h3>
183
+ <nav aria-label="Product categories">
184
+ <a href="/catalog">Electronics</a>
185
+ <a href="/catalog">Home</a>
186
+ </nav>
187
+ <button type="button" class="reveal-btn">Reveal</button>
188
+ <code class="answer" hidden>page.getByRole('navigation', { name: 'Product categories' })</code>
189
+ </section>
190
+
191
+ <section class="locator-demo">
192
+ <h3>23. Role: heading</h3>
193
+ <h4>Frequently bought together</h4>
194
+ <button type="button" class="reveal-btn">Reveal</button>
195
+ <code class="answer" hidden>page.getByRole('heading', { name: 'Frequently bought together', level: 4 })</code>
196
+ </section>
197
+
198
+ <section class="locator-demo">
199
+ <h3>24. Role: list</h3>
200
+ <ul class="review-list" aria-label="Order perks">
201
+ <li class="review-item">Free shipping over $50</li>
202
+ <li class="review-item">30-day returns</li>
203
+ </ul>
204
+ <p class="hint-text">Named so this list is unambiguous โ€” the page has other <code>&lt;ul&gt;</code> elements too.</p>
205
+ <button type="button" class="reveal-btn">Reveal</button>
206
+ <code class="answer" hidden>page.getByRole('list', { name: 'Order perks' })</code>
207
+ </section>
208
+
209
+ <section class="locator-demo">
210
+ <h3>25. Role: listitem</h3>
211
+ <ul class="review-list" aria-label="Checkout benefits">
212
+ <li class="review-item">Free shipping over $50</li>
213
+ <li class="review-item">30-day returns</li>
214
+ </ul>
215
+ <button type="button" class="reveal-btn">Reveal</button>
216
+ <code class="answer" hidden>page.getByRole('list', { name: 'Checkout benefits' }).getByRole('listitem').filter({ hasText: '30-day returns' })</code>
217
+ </section>
218
+
219
+ <section class="locator-demo">
220
+ <h3>26. Role: table</h3>
221
+ <table aria-label="Recent orders">
222
+ <thead><tr><th scope="col">Order</th><th scope="col">Total (USD)</th></tr></thead>
223
+ <tbody><tr><td>#1029</td><td>69.00</td></tr></tbody>
224
+ </table>
225
+ <p class="hint-text">Named so this table is unambiguous โ€” three more demo tables follow below.</p>
226
+ <button type="button" class="reveal-btn">Reveal</button>
227
+ <code class="answer" hidden>page.getByRole('table', { name: 'Recent orders' })</code>
228
+ </section>
229
+
230
+ <section class="locator-demo">
231
+ <h3>27. Role: row</h3>
232
+ <table aria-label="Order totals">
233
+ <thead><tr><th scope="col">Order</th><th scope="col">Total (USD)</th></tr></thead>
234
+ <tbody><tr><td>#1029</td><td>69.00</td></tr></tbody>
235
+ </table>
236
+ <button type="button" class="reveal-btn">Reveal</button>
237
+ <code class="answer" hidden>page.getByRole('table', { name: 'Order totals' }).getByRole('row').filter({ hasText: '#1029' })</code>
238
+ </section>
239
+
240
+ <section class="locator-demo">
241
+ <h3>28. Role: cell</h3>
242
+ <table aria-label="Order breakdown">
243
+ <thead><tr><th scope="col">Order</th><th scope="col">Total (USD)</th></tr></thead>
244
+ <tbody><tr><td>#1029</td><td>69.00</td></tr></tbody>
245
+ </table>
246
+ <button type="button" class="reveal-btn">Reveal</button>
247
+ <code class="answer" hidden>page.getByRole('table', { name: 'Order breakdown' }).getByRole('cell', { name: '69.00' })</code>
248
+ </section>
249
+
250
+ <section class="locator-demo">
251
+ <h3>29. Role: columnheader</h3>
252
+ <table aria-label="Order columns">
253
+ <thead><tr><th scope="col">Order</th><th scope="col">Total (USD)</th></tr></thead>
254
+ <tbody><tr><td>#1029</td><td>69.00</td></tr></tbody>
255
+ </table>
256
+ <button type="button" class="reveal-btn">Reveal</button>
257
+ <code class="answer" hidden>page.getByRole('table', { name: 'Order columns' }).getByRole('columnheader', { name: 'Total (USD)' })</code>
258
+ </section>
259
+
260
+ <section class="locator-demo">
261
+ <h3>30. Role: dialog</h3>
262
+ <div role="dialog" aria-label="Confirm deletion" class="demo-dialog">
263
+ <p>Are you sure you want to delete this address?</p>
264
+ </div>
265
+ <button type="button" class="reveal-btn">Reveal</button>
266
+ <code class="answer" hidden>page.getByRole('dialog', { name: 'Confirm deletion' })</code>
267
+ </section>
268
+
269
+ <section class="locator-demo">
270
+ <h3>31. Role: alert</h3>
271
+ <div role="alert" class="badge-warn">Only 2 left in stock</div>
272
+ <button type="button" class="reveal-btn">Reveal</button>
273
+ <code class="answer" hidden>page.getByRole('alert')</code>
274
+ </section>
275
+
276
+ <section class="locator-demo">
277
+ <h3>32. Role: progressbar</h3>
278
+ <label for="demo-upload-progress">Uploading receipt</label>
279
+ <progress id="demo-upload-progress" value="70" max="100"></progress>
280
+ <button type="button" class="reveal-btn">Reveal</button>
281
+ <code class="answer" hidden>page.getByRole('progressbar', { name: 'Uploading receipt' })</code>
282
+ </section>
283
+
284
+ <section class="locator-demo">
285
+ <h3>33. Role: status</h3>
286
+ <output class="demo-status">Cart updated</output>
287
+ <button type="button" class="reveal-btn">Reveal</button>
288
+ <code class="answer" hidden>page.getByRole('status')</code>
289
+ </section>
290
+
291
+ <section class="locator-demo">
292
+ <h3>34. Role: img</h3>
293
+ <svg role="img" aria-label="Verified seller badge" width="24" height="24"><circle cx="12" cy="12" r="10" fill="#1f7a5c"></circle></svg>
294
+ <p class="hint-text">An inline SVG can't take <code>alt</code>, so it needs <code>role="img"</code> plus <code>aria-label</code> instead.</p>
295
+ <button type="button" class="reveal-btn">Reveal</button>
296
+ <code class="answer" hidden>page.getByRole('img', { name: 'Verified seller badge' })</code>
297
+ </section>
298
+
299
+ <section class="locator-demo">
300
+ <h3>35. Role: tab / tabpanel</h3>
301
+ <div role="tablist" aria-label="Product info">
302
+ <button type="button" role="tab" aria-selected="false">Overview</button>
303
+ <button type="button" role="tab" aria-selected="false">Specs</button>
304
+ </div>
305
+ <div role="tabpanel" aria-label="Overview panel">Lightweight wireless mouse with 18-month battery life.</div>
306
+ <button type="button" class="reveal-btn">Reveal</button>
307
+ <code class="answer" hidden>page.getByRole('tab', { name: 'Specs' })
308
+ page.getByRole('tabpanel', { name: 'Overview panel' })</code>
309
+ </section>
310
+
311
+ <section class="locator-demo">
312
+ <h3>36. Role: search</h3>
313
+ <form role="search">
314
+ <label for="demo-site-search">Search the store</label>
315
+ <input type="search" id="demo-site-search">
316
+ </form>
317
+ <button type="button" class="reveal-btn">Reveal</button>
318
+ <code class="answer" hidden>page.getByRole('search')</code>
319
+ </section>
320
+
321
+ </div>
322
+
323
+ <%- include('partials/footer') %>
@@ -0,0 +1,22 @@
1
+ <%- include('partials/head', { title: 'Login' }) %>
2
+ <%- include('partials/header') %>
3
+
4
+ <div class="auth-form-wrap">
5
+ <h1>Login</h1>
6
+ <% if (error === 'invalid') { %>
7
+ <p class="form-error" role="alert">Incorrect username or password.</p>
8
+ <% } %>
9
+ <form method="post" action="/login" class="auth-form">
10
+ <label for="username">Username</label>
11
+ <input type="text" id="username" name="username" required autocomplete="username">
12
+
13
+ <label for="password">Password</label>
14
+ <input type="password" id="password" name="password" required autocomplete="current-password">
15
+
16
+ <button type="submit" class="btn btn-primary" data-testid="<%= tid('login-submit') %>">Log in</button>
17
+ </form>
18
+ <p>Seeded account: <code>qa_student</code> / <code>Passw0rd!</code></p>
19
+ <p>No account? <a href="/register">Register here</a>.</p>
20
+ </div>
21
+
22
+ <%- include('partials/footer') %>
@@ -0,0 +1,33 @@
1
+ <%- include('partials/head', { title: 'Order history' }) %>
2
+ <%- include('partials/header') %>
3
+
4
+ <h1>Order history</h1>
5
+ <p class="hint-text">This table is deliberately plain HTML โ€” no ids, no classes, no data-testid โ€” the way a lot of legacy internal tools actually look. Locate rows with XPath.</p>
6
+
7
+ <table>
8
+ <tr>
9
+ <td><b>Order</b></td>
10
+ <td><b>Date</b></td>
11
+ <td><b>Item</b></td>
12
+ <td><b>Qty</b></td>
13
+ <td><b>Total</b></td>
14
+ <td><b>Status</b></td>
15
+ <td></td>
16
+ </tr>
17
+ <% orders.forEach(function(o) { %>
18
+ <tr>
19
+ <td><%= o.id %></td>
20
+ <td><%= o.created_at %></td>
21
+ <td><%= o.item %></td>
22
+ <td><%= o.qty %></td>
23
+ <td><%= o.total.toFixed(2) %></td>
24
+ <td><%= o.status %></td>
25
+ <td><a href="/account/orders/<%= o.id %>/invoice" target="_blank" rel="noopener">Open invoice โ†—</a></td>
26
+ </tr>
27
+ <% }); %>
28
+ <% if (orders.length === 0) { %>
29
+ <tr><td colspan="7">No orders yet.</td></tr>
30
+ <% } %>
31
+ </table>
32
+
33
+ <%- include('partials/footer') %>
@@ -0,0 +1,18 @@
1
+ </main>
2
+
3
+ <footer class="site-footer">
4
+ <span>QA Copilot Lab โ€” local practice target for the Playwright MCP for QA course</span>
5
+ </footer>
6
+
7
+ <script src="/js/cart.js"></script>
8
+ <script src="/js/accordion.js"></script>
9
+ <script src="/js/tabs.js"></script>
10
+ <script src="/js/star-rating.js"></script>
11
+ <script src="/js/toast.js"></script>
12
+ <script src="/js/confirm-dialog.js"></script>
13
+ <script src="/js/theme-toggle.js"></script>
14
+ <script src="/js/drag-drop.js"></script>
15
+ <script src="/js/autocomplete.js"></script>
16
+ <script src="/js/reveal-answer.js"></script>
17
+ </body>
18
+ </html>
@@ -0,0 +1,9 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= typeof title !== 'undefined' ? title + ' ยท QA Copilot Lab' : 'QA Copilot Lab' %></title>
7
+ <link rel="stylesheet" href="/css/style.css">
8
+ </head>
9
+ <body>