@techninja/clearstack 0.2.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/LICENSE +21 -0
- package/README.md +81 -0
- package/bin/cli.js +62 -0
- package/docs/BACKEND_API_SPEC.md +281 -0
- package/docs/BUILD_LOG.md +193 -0
- package/docs/COMPONENT_PATTERNS.md +481 -0
- package/docs/CONVENTIONS.md +226 -0
- package/docs/FRONTEND_IMPLEMENTATION_RULES.md +239 -0
- package/docs/JSDOC_TYPING.md +86 -0
- package/docs/QUICKSTART.md +190 -0
- package/docs/SERVER_AND_DEPS.md +163 -0
- package/docs/STATE_AND_ROUTING.md +363 -0
- package/docs/TESTING.md +268 -0
- package/docs/app-spec/ENTITIES.md +37 -0
- package/docs/app-spec/README.md +19 -0
- package/lib/check.js +115 -0
- package/lib/copy.js +43 -0
- package/lib/init.js +73 -0
- package/lib/package-gen.js +83 -0
- package/lib/update.js +73 -0
- package/package.json +69 -0
- package/templates/fullstack/data/seed.json +1 -0
- package/templates/fullstack/src/api/db.js +75 -0
- package/templates/fullstack/src/api/entities.js +114 -0
- package/templates/fullstack/src/api/events.js +35 -0
- package/templates/fullstack/src/api/schemas.js +104 -0
- package/templates/fullstack/src/api/validate.js +52 -0
- package/templates/fullstack/src/pages/home/home-view.js +19 -0
- package/templates/fullstack/src/router/index.js +16 -0
- package/templates/fullstack/src/server.js +46 -0
- package/templates/fullstack/src/store/AppState.js +33 -0
- package/templates/fullstack/src/store/UserPrefs.js +31 -0
- package/templates/fullstack/src/store/realtimeSync.js +54 -0
- package/templates/shared/.configs/.prettierrc +8 -0
- package/templates/shared/.configs/eslint.config.js +64 -0
- package/templates/shared/.configs/jsconfig.json +24 -0
- package/templates/shared/.configs/web-test-runner.config.js +8 -0
- package/templates/shared/.env +9 -0
- package/templates/shared/.github/ISSUE_TEMPLATE/bug_report.md +42 -0
- package/templates/shared/.github/ISSUE_TEMPLATE/feature_request.md +30 -0
- package/templates/shared/.github/ISSUE_TEMPLATE/spec_correction.md +26 -0
- package/templates/shared/.github/pull_request_template.md +51 -0
- package/templates/shared/.github/workflows/spec.yml +46 -0
- package/templates/shared/README.md +22 -0
- package/templates/shared/docs/app-spec/README.md +40 -0
- package/templates/shared/docs/clearstack/BACKEND_API_SPEC.md +281 -0
- package/templates/shared/docs/clearstack/BUILD_LOG.md +193 -0
- package/templates/shared/docs/clearstack/COMPONENT_PATTERNS.md +481 -0
- package/templates/shared/docs/clearstack/CONVENTIONS.md +226 -0
- package/templates/shared/docs/clearstack/FRONTEND_IMPLEMENTATION_RULES.md +239 -0
- package/templates/shared/docs/clearstack/JSDOC_TYPING.md +86 -0
- package/templates/shared/docs/clearstack/QUICKSTART.md +190 -0
- package/templates/shared/docs/clearstack/SERVER_AND_DEPS.md +163 -0
- package/templates/shared/docs/clearstack/STATE_AND_ROUTING.md +363 -0
- package/templates/shared/docs/clearstack/TESTING.md +268 -0
- package/templates/shared/public/index.html +26 -0
- package/templates/shared/scripts/build-icons.js +86 -0
- package/templates/shared/scripts/vendor-deps.js +25 -0
- package/templates/shared/src/components/atoms/app-badge/app-badge.css +4 -0
- package/templates/shared/src/components/atoms/app-badge/app-badge.js +23 -0
- package/templates/shared/src/components/atoms/app-badge/app-badge.test.js +26 -0
- package/templates/shared/src/components/atoms/app-badge/index.js +1 -0
- package/templates/shared/src/components/atoms/app-button/app-button.css +3 -0
- package/templates/shared/src/components/atoms/app-button/app-button.js +41 -0
- package/templates/shared/src/components/atoms/app-button/app-button.test.js +43 -0
- package/templates/shared/src/components/atoms/app-button/index.js +1 -0
- package/templates/shared/src/components/atoms/app-icon/app-icon.css +4 -0
- package/templates/shared/src/components/atoms/app-icon/app-icon.js +57 -0
- package/templates/shared/src/components/atoms/app-icon/app-icon.test.js +30 -0
- package/templates/shared/src/components/atoms/app-icon/index.js +1 -0
- package/templates/shared/src/components/atoms/theme-toggle/index.js +1 -0
- package/templates/shared/src/components/atoms/theme-toggle/theme-toggle.css +10 -0
- package/templates/shared/src/components/atoms/theme-toggle/theme-toggle.js +42 -0
- package/templates/shared/src/styles/buttons.css +79 -0
- package/templates/shared/src/styles/components.css +31 -0
- package/templates/shared/src/styles/forms.css +20 -0
- package/templates/shared/src/styles/reset.css +32 -0
- package/templates/shared/src/styles/shared.css +135 -0
- package/templates/shared/src/styles/tokens.css +65 -0
- package/templates/shared/src/utils/formatDate.js +41 -0
- package/templates/shared/src/utils/statusColors.js +60 -0
- package/templates/static/src/pages/home/home-view.js +38 -0
- package/templates/static/src/router/index.js +16 -0
- package/templates/static/src/store/AppState.js +26 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# State & Routing
|
|
2
|
+
## Store, Routing, Unified App State & Realtime Sync
|
|
3
|
+
|
|
4
|
+
> How data flows through the application.
|
|
5
|
+
> See [FRONTEND_IMPLEMENTATION_RULES.md](./FRONTEND_IMPLEMENTATION_RULES.md) for
|
|
6
|
+
> project structure and [BACKEND_API_SPEC.md](./BACKEND_API_SPEC.md) for the
|
|
7
|
+
> server-side data contract.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## State Management
|
|
12
|
+
|
|
13
|
+
### Component-Local State
|
|
14
|
+
|
|
15
|
+
For state that belongs to a single component instance, use plain properties:
|
|
16
|
+
|
|
17
|
+
```javascript
|
|
18
|
+
export default define({
|
|
19
|
+
tag: 'app-toggle',
|
|
20
|
+
open: false,
|
|
21
|
+
render: ({ open }) => html`
|
|
22
|
+
<button onclick="${host => { host.open = !host.open; }}">
|
|
23
|
+
${open ? 'Close' : 'Open'}
|
|
24
|
+
</button>
|
|
25
|
+
`,
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Local state resets when the component disconnects from the DOM.
|
|
30
|
+
|
|
31
|
+
### Shared State via Store
|
|
32
|
+
|
|
33
|
+
For state shared across components or persisted beyond a component's lifetime,
|
|
34
|
+
use `store()` with a model definition.
|
|
35
|
+
|
|
36
|
+
#### Singleton Model (App-Wide State)
|
|
37
|
+
|
|
38
|
+
One instance, no `id`. Lives in `src/store/AppState.js`:
|
|
39
|
+
|
|
40
|
+
```javascript
|
|
41
|
+
/** @typedef {{ theme: 'light'|'dark', sidebarOpen: boolean }} AppState */
|
|
42
|
+
|
|
43
|
+
/** @type {import('hybrids').Model<AppState>} */
|
|
44
|
+
const AppState = {
|
|
45
|
+
theme: 'light',
|
|
46
|
+
sidebarOpen: false,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default AppState;
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
#### localStorage Connector: Return `{}`, Never `undefined`
|
|
53
|
+
|
|
54
|
+
When a localStorage-backed singleton has no stored value yet (first visit),
|
|
55
|
+
the `get` connector **must return `{}`**, not `undefined` or `null`.
|
|
56
|
+
|
|
57
|
+
Hybrids treats `undefined` from `get` as a failed fetch and puts the model
|
|
58
|
+
into an error state. Returning `{}` lets hybrids merge with the model's
|
|
59
|
+
default values, so the model initializes cleanly:
|
|
60
|
+
|
|
61
|
+
```javascript
|
|
62
|
+
[store.connect]: {
|
|
63
|
+
// ✅ GOOD — returns empty object, hybrids merges with defaults
|
|
64
|
+
get: () => {
|
|
65
|
+
const raw = localStorage.getItem('appState');
|
|
66
|
+
return raw ? JSON.parse(raw) : {};
|
|
67
|
+
},
|
|
68
|
+
// ❌ BAD — undefined triggers error state
|
|
69
|
+
// get: () => {
|
|
70
|
+
// const raw = localStorage.getItem('appState');
|
|
71
|
+
// return raw ? JSON.parse(raw) : undefined;
|
|
72
|
+
// },
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This applies to all localStorage-backed singletons (`AppState`, `UserPrefs`).
|
|
77
|
+
|
|
78
|
+
Consume in any component:
|
|
79
|
+
|
|
80
|
+
```javascript
|
|
81
|
+
import { store, html, define } from 'hybrids';
|
|
82
|
+
import AppState from '../../store/AppState.js';
|
|
83
|
+
|
|
84
|
+
export default define({
|
|
85
|
+
tag: 'theme-toggle',
|
|
86
|
+
state: store(AppState),
|
|
87
|
+
render: ({ state }) => html`
|
|
88
|
+
<button onclick="${host => {
|
|
89
|
+
store.set(host.state, { theme: host.state.theme === 'light' ? 'dark' : 'light' });
|
|
90
|
+
}}">
|
|
91
|
+
Theme: ${state.theme}
|
|
92
|
+
</button>
|
|
93
|
+
`,
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### Enumerable Model (Entity Records)
|
|
98
|
+
|
|
99
|
+
Has `id: true`. Lives in `src/store/UserModel.js`:
|
|
100
|
+
|
|
101
|
+
```javascript
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {Object} User
|
|
104
|
+
* @property {string} id
|
|
105
|
+
* @property {string} firstName
|
|
106
|
+
* @property {string} lastName
|
|
107
|
+
* @property {string} email
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
/** @type {import('hybrids').Model<User>} */
|
|
111
|
+
const UserModel = {
|
|
112
|
+
id: true,
|
|
113
|
+
firstName: '',
|
|
114
|
+
lastName: '',
|
|
115
|
+
email: '',
|
|
116
|
+
[store.connect]: {
|
|
117
|
+
get: (id) => fetch(`/api/users/${id}`).then(r => r.json()),
|
|
118
|
+
set: (id, values) => fetch(`/api/users/${id}`, {
|
|
119
|
+
method: 'PUT',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify(values),
|
|
122
|
+
}).then(r => r.json()),
|
|
123
|
+
list: (id) => fetch(`/api/users?${new URLSearchParams(id)}`).then(r => r.json()),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export default UserModel;
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
#### Store API Quick Reference
|
|
131
|
+
|
|
132
|
+
| Method | Purpose |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `store(Model)` | Descriptor — binds model to a component property |
|
|
135
|
+
| `store.get(Model, id)` | Get a cached instance (triggers fetch if needed) |
|
|
136
|
+
| `store.set(model, values)` | Update (async, returns Promise) |
|
|
137
|
+
| `store.sync(model, values)` | Update (sync, immediate) |
|
|
138
|
+
| `store.pending(model)` | `false` or `Promise` while loading |
|
|
139
|
+
| `store.ready(model)` | `true` when loaded and valid |
|
|
140
|
+
| `store.error(model)` | `false` or `Error` |
|
|
141
|
+
| `store.clear(Model)` | Invalidate singular model cache |
|
|
142
|
+
| `store.clear([Model])` | Invalidate list cache — **required for list stores** |
|
|
143
|
+
| `store.submit(draft)` | Submit draft mode changes |
|
|
144
|
+
| `store.resolve(Model, id)` | Returns Promise that resolves when ready |
|
|
145
|
+
|
|
146
|
+
#### Decision Tree: Local vs Shared State
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
Is this state used by more than one component?
|
|
150
|
+
YES → store()
|
|
151
|
+
NO → Does it need to survive component disconnect?
|
|
152
|
+
YES → store()
|
|
153
|
+
NO → plain property
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
#### Guarding List Item Access
|
|
157
|
+
|
|
158
|
+
`store.ready(list)` checks if the list itself has loaded, but individual
|
|
159
|
+
items within the list can still be in a pending state (e.g. after a cache
|
|
160
|
+
clear triggers re-fetch). Always guard property access on list items:
|
|
161
|
+
|
|
162
|
+
```javascript
|
|
163
|
+
// ❌ BAD — task may be pending, accessing .title throws
|
|
164
|
+
tasks.map((task) => html`<span>${task.title}</span>`)
|
|
165
|
+
|
|
166
|
+
// ✅ GOOD — guard each item, show fallback for pending items
|
|
167
|
+
tasks.map((task) => store.ready(task)
|
|
168
|
+
? html`<span>${task.title}</span>`
|
|
169
|
+
: html`<span class="spinner"></span>`
|
|
170
|
+
)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
This is especially important after batch operations (e.g. drag reorder)
|
|
174
|
+
where multiple items are invalidated simultaneously.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Routing
|
|
179
|
+
|
|
180
|
+
### Router Shell
|
|
181
|
+
|
|
182
|
+
The app has one router shell component that manages the view stack.
|
|
183
|
+
Lives in `src/router/index.js`:
|
|
184
|
+
|
|
185
|
+
```javascript
|
|
186
|
+
import { define, html, router } from 'hybrids';
|
|
187
|
+
import HomeView from '../pages/home/index.js';
|
|
188
|
+
|
|
189
|
+
export default define({
|
|
190
|
+
tag: 'app-router',
|
|
191
|
+
stack: router(HomeView, { url: '/' }),
|
|
192
|
+
render: ({ stack }) => html`
|
|
193
|
+
<template layout="column height::100vh">
|
|
194
|
+
${stack}
|
|
195
|
+
</template>
|
|
196
|
+
`,
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### View Configuration
|
|
201
|
+
|
|
202
|
+
Each page declares its routing config via `[router.connect]`:
|
|
203
|
+
|
|
204
|
+
```javascript
|
|
205
|
+
import { define, html, router } from 'hybrids';
|
|
206
|
+
import AboutView from '../about/index.js';
|
|
207
|
+
|
|
208
|
+
export default define({
|
|
209
|
+
tag: 'home-view',
|
|
210
|
+
[router.connect]: {
|
|
211
|
+
url: '/',
|
|
212
|
+
stack: [AboutView],
|
|
213
|
+
},
|
|
214
|
+
render: () => html`
|
|
215
|
+
<template layout="column gap:2 padding:2">
|
|
216
|
+
<h1>Home</h1>
|
|
217
|
+
<a href="${router.url(AboutView)}">About</a>
|
|
218
|
+
</template>
|
|
219
|
+
`,
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Routing Patterns
|
|
224
|
+
|
|
225
|
+
| Pattern | Code |
|
|
226
|
+
|---|---|
|
|
227
|
+
| Navigate to view | `<a href="${router.url(View)}">` |
|
|
228
|
+
| Navigate with params | `router.url(View, { id: '42' })` |
|
|
229
|
+
| Back button | `<a href="${router.backUrl()}">Back</a>` |
|
|
230
|
+
| Check active view | `router.active(View)` |
|
|
231
|
+
| Guarded route | `guard: () => isAuthenticated()` |
|
|
232
|
+
| Dialog overlay | `dialog: true` on the view config |
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Unified App State
|
|
237
|
+
|
|
238
|
+
The frontend maintains a single `AppState` singleton that acts as the
|
|
239
|
+
**source of truth for UI state**. Entity data lives in enumerable store
|
|
240
|
+
models that sync with the backend.
|
|
241
|
+
|
|
242
|
+
### Architecture
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
Backend REST API
|
|
246
|
+
↕ fetch / SSE
|
|
247
|
+
Store Models (UserModel, etc.) ←→ [store.connect] storage
|
|
248
|
+
↕ store()
|
|
249
|
+
Component Properties
|
|
250
|
+
↕ render()
|
|
251
|
+
DOM
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### AppState vs Entity Models
|
|
255
|
+
|
|
256
|
+
| Concern | Where |
|
|
257
|
+
|---|---|
|
|
258
|
+
| Theme, sidebar, UI flags | `AppState` (singleton) |
|
|
259
|
+
| User records, posts, etc. | `UserModel`, `PostModel` (enumerable) |
|
|
260
|
+
| Form draft state | `store(Model, { draft: true })` |
|
|
261
|
+
| Route state | `router()` — managed by hybrids |
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Realtime Sync
|
|
266
|
+
|
|
267
|
+
For live data updates, the backend pushes events via **Server-Sent Events
|
|
268
|
+
(SSE)**. The frontend listens and invalidates the relevant store cache.
|
|
269
|
+
|
|
270
|
+
### Frontend: SSE Listener
|
|
271
|
+
|
|
272
|
+
Lives in `src/utils/realtimeSync.js`:
|
|
273
|
+
|
|
274
|
+
```javascript
|
|
275
|
+
import { store } from 'hybrids';
|
|
276
|
+
|
|
277
|
+
export function connectRealtime(url, modelMap) {
|
|
278
|
+
const source = new EventSource(url);
|
|
279
|
+
|
|
280
|
+
source.addEventListener('update', (event) => {
|
|
281
|
+
const { type } = JSON.parse(event.data);
|
|
282
|
+
const Model = modelMap[type];
|
|
283
|
+
if (Model) store.clear(Model); // full clear triggers re-fetch
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
source.addEventListener('error', () => {
|
|
287
|
+
source.close();
|
|
288
|
+
setTimeout(() => connectRealtime(url, modelMap), 5000);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return () => source.close();
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`store.clear(Model)` fully invalidates the cache for that model type.
|
|
296
|
+
Any component bound to the model via `store()` will automatically re-fetch
|
|
297
|
+
from the API. This is the mechanism that makes multi-user realtime work —
|
|
298
|
+
when user A creates a task, user B's task list updates automatically.
|
|
299
|
+
|
|
300
|
+
For the local user, form submit handlers also call `store.clear(Model)`
|
|
301
|
+
immediately after a successful save. The SSE event that follows is
|
|
302
|
+
redundant for the local user but ensures other connected clients update.
|
|
303
|
+
|
|
304
|
+
### Backend: SSE Endpoint
|
|
305
|
+
|
|
306
|
+
See [BACKEND_API_SPEC.md](./BACKEND_API_SPEC.md) for the `/api/events` SSE
|
|
307
|
+
contract.
|
|
308
|
+
|
|
309
|
+
### Wiring It Up
|
|
310
|
+
|
|
311
|
+
In the router shell's `connect` descriptor:
|
|
312
|
+
|
|
313
|
+
```javascript
|
|
314
|
+
import { connectRealtime } from '../utils/realtimeSync.js';
|
|
315
|
+
import UserModel from '../store/UserModel.js';
|
|
316
|
+
|
|
317
|
+
export default define({
|
|
318
|
+
tag: 'app-router',
|
|
319
|
+
stack: router(HomeView, { url: '/' }),
|
|
320
|
+
connection: {
|
|
321
|
+
value: undefined,
|
|
322
|
+
connect(host) {
|
|
323
|
+
const disconnect = connectRealtime('/api/events', {
|
|
324
|
+
user: UserModel,
|
|
325
|
+
});
|
|
326
|
+
return disconnect;
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
render: ({ stack }) => html`
|
|
330
|
+
<template layout="column height::100vh">${stack}</template>
|
|
331
|
+
`,
|
|
332
|
+
});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
When the backend sends `{ type: "user", id: "42" }` over SSE, the store
|
|
336
|
+
cache for `UserModel` is cleared, and any component displaying that user
|
|
337
|
+
re-fetches automatically.
|
|
338
|
+
|
|
339
|
+
### Debouncing Batch Operations
|
|
340
|
+
|
|
341
|
+
Operations like drag-to-reorder send multiple PUTs, each triggering an SSE
|
|
342
|
+
event. Without debouncing, each event clears the store and triggers a
|
|
343
|
+
re-render while the previous render is still pending — causing cascading
|
|
344
|
+
errors.
|
|
345
|
+
|
|
346
|
+
The `connectRealtime()` utility debounces by entity type: multiple SSE
|
|
347
|
+
events within 300ms trigger only one `store.clear()`. This means a reorder
|
|
348
|
+
of 5 tasks sends 5 PUTs → 5 SSE events → 1 store clear after 300ms.
|
|
349
|
+
|
|
350
|
+
```javascript
|
|
351
|
+
// Inside connectRealtime — debounce per entity type
|
|
352
|
+
const timers = {};
|
|
353
|
+
source.addEventListener('update', (event) => {
|
|
354
|
+
const { type } = JSON.parse(event.data);
|
|
355
|
+
clearTimeout(timers[type]);
|
|
356
|
+
timers[type] = setTimeout(() => {
|
|
357
|
+
store.clear([Model]); // one clear after the batch settles
|
|
358
|
+
}, 300);
|
|
359
|
+
});
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
For the local user, the UI should not call `store.clear()` explicitly
|
|
363
|
+
after batch operations — let the debounced SSE handler do it once.
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
## Philosophy, Tools & Patterns
|
|
3
|
+
|
|
4
|
+
> How we test in a no-build web component project.
|
|
5
|
+
> See [FRONTEND_IMPLEMENTATION_RULES.md](./FRONTEND_IMPLEMENTATION_RULES.md) for
|
|
6
|
+
> the full specification index.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Philosophy
|
|
11
|
+
|
|
12
|
+
### Test What Matters, When It Matters
|
|
13
|
+
|
|
14
|
+
Tests are written **alongside implementation, not after**. Each phase of
|
|
15
|
+
development produces tests before moving to the next phase. This catches
|
|
16
|
+
bugs at the boundary where they're introduced, not 5 layers up.
|
|
17
|
+
|
|
18
|
+
### Core Principles
|
|
19
|
+
|
|
20
|
+
| Principle | Rule |
|
|
21
|
+
|---|---|
|
|
22
|
+
| Test at the right level | Pure logic → unit. Components → browser. API → integration. |
|
|
23
|
+
| No mocking the framework | Don't mock `html`, `store`, or `define`. Test through them. |
|
|
24
|
+
| Real browser for components | Web components need a real DOM. No jsdom, no happy-dom. |
|
|
25
|
+
| Zero build for tests | Test files are ES modules, same as app code. |
|
|
26
|
+
| Small test files | Same 150-line rule applies to test files. |
|
|
27
|
+
| Test behavior, not implementation | Assert what the user sees, not internal state shape. |
|
|
28
|
+
| Co-locate tests | Tests live next to the code they test. |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Tools
|
|
33
|
+
|
|
34
|
+
### Two Test Runners, Clear Boundaries
|
|
35
|
+
|
|
36
|
+
| Tool | Tests | Runs in |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `node:test` (built-in) | Server, utils, store model shapes | Node.js |
|
|
39
|
+
| `@web/test-runner` | Components, pages, browser integration | Real Chromium |
|
|
40
|
+
|
|
41
|
+
No other test frameworks. No Jest, no Mocha, no Jasmine.
|
|
42
|
+
|
|
43
|
+
### Why Two Runners
|
|
44
|
+
|
|
45
|
+
- **`node:test`** is zero-dependency and fast. Perfect for pure functions
|
|
46
|
+
and server-side code that doesn't touch the DOM.
|
|
47
|
+
- **`@web/test-runner`** launches a real browser, serves ES modules natively,
|
|
48
|
+
and respects import maps. Components mount into a real DOM with shadow roots,
|
|
49
|
+
events, and rendering — exactly like production.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## File Conventions
|
|
54
|
+
|
|
55
|
+
### Test File Location
|
|
56
|
+
|
|
57
|
+
Tests live **next to the code they test**, with a `.test.js` suffix:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
src/utils/
|
|
61
|
+
├── formatDate.js
|
|
62
|
+
└── formatDate.test.js ← node:test
|
|
63
|
+
|
|
64
|
+
src/components/atoms/app-button/
|
|
65
|
+
├── app-button.js
|
|
66
|
+
├── app-button.css
|
|
67
|
+
├── app-button.test.js ← @web/test-runner
|
|
68
|
+
└── index.js
|
|
69
|
+
|
|
70
|
+
src/store/
|
|
71
|
+
├── UserModel.js
|
|
72
|
+
└── UserModel.test.js ← node:test
|
|
73
|
+
|
|
74
|
+
src/server.js
|
|
75
|
+
server.test.js ← node:test
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Test File Naming
|
|
79
|
+
|
|
80
|
+
| Code file | Test file |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `app-button.js` | `app-button.test.js` |
|
|
83
|
+
| `formatDate.js` | `formatDate.test.js` |
|
|
84
|
+
| `UserModel.js` | `UserModel.test.js` |
|
|
85
|
+
| `src/server.js` | `server.test.js` |
|
|
86
|
+
|
|
87
|
+
### What Gets Tested
|
|
88
|
+
|
|
89
|
+
| Layer | What to assert |
|
|
90
|
+
|---|---|
|
|
91
|
+
| **Utils** | Input → output. Edge cases. |
|
|
92
|
+
| **Store models** | Shape is correct. Computed fields work. Storage connector URLs are right. |
|
|
93
|
+
| **Atoms** | Renders correct HTML. Props reflect to attributes. Events fire. |
|
|
94
|
+
| **Molecules** | Child atoms are present. Composed behavior works. |
|
|
95
|
+
| **Organisms** | Store integration. Data flows to children. |
|
|
96
|
+
| **Server API** | CRUD responses. Schema endpoint. Status codes. |
|
|
97
|
+
| **Pages** | Don't unit-test pages. Test via manual or E2E if needed. |
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Patterns
|
|
102
|
+
|
|
103
|
+
### Node Tests (utils, store, server)
|
|
104
|
+
|
|
105
|
+
Using the built-in `node:test` and `node:assert`:
|
|
106
|
+
|
|
107
|
+
```javascript
|
|
108
|
+
// src/utils/formatDate.test.js
|
|
109
|
+
import { describe, it } from 'node:test';
|
|
110
|
+
import assert from 'node:assert/strict';
|
|
111
|
+
import { formatDate } from './formatDate.js';
|
|
112
|
+
|
|
113
|
+
describe('formatDate', () => {
|
|
114
|
+
it('formats ISO string to readable date', () => {
|
|
115
|
+
assert.equal(formatDate('2024-01-15T09:00:00Z'), 'Jan 15, 2024');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns empty string for null', () => {
|
|
119
|
+
assert.equal(formatDate(null), '');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Run with:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
node --test src/utils/*.test.js
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Server Tests
|
|
131
|
+
|
|
132
|
+
Test the API endpoints using fetch against a running server:
|
|
133
|
+
|
|
134
|
+
```javascript
|
|
135
|
+
// server.test.js
|
|
136
|
+
import { describe, it, before, after } from 'node:test';
|
|
137
|
+
import assert from 'node:assert/strict';
|
|
138
|
+
|
|
139
|
+
let server;
|
|
140
|
+
const BASE = 'http://localhost:3001';
|
|
141
|
+
|
|
142
|
+
before(async () => {
|
|
143
|
+
// Import and start server on test port
|
|
144
|
+
const app = await import('./src/server.js');
|
|
145
|
+
server = app.start(3001);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
after(() => server?.close());
|
|
149
|
+
|
|
150
|
+
describe('GET /api/users', () => {
|
|
151
|
+
it('returns a list with data array', async () => {
|
|
152
|
+
const res = await fetch(`${BASE}/api/users`);
|
|
153
|
+
const body = await res.json();
|
|
154
|
+
assert.equal(res.status, 200);
|
|
155
|
+
assert.ok(Array.isArray(body.data));
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('GET /api/users?schema=true', () => {
|
|
160
|
+
it('returns JSON Schema with properties', async () => {
|
|
161
|
+
const res = await fetch(`${BASE}/api/users?schema=true`);
|
|
162
|
+
const schema = await res.json();
|
|
163
|
+
assert.equal(schema.title, 'User');
|
|
164
|
+
assert.ok(schema.properties.firstName);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Component Tests (browser)
|
|
170
|
+
|
|
171
|
+
Using `@web/test-runner` with `@open-wc/testing`:
|
|
172
|
+
|
|
173
|
+
```javascript
|
|
174
|
+
// src/components/atoms/app-button/app-button.test.js
|
|
175
|
+
import { fixture, html, expect } from '@open-wc/testing';
|
|
176
|
+
import './app-button.js';
|
|
177
|
+
|
|
178
|
+
describe('app-button', () => {
|
|
179
|
+
it('renders with label', async () => {
|
|
180
|
+
const el = await fixture(html`<app-button label="Click me"></app-button>`);
|
|
181
|
+
const button = el.shadowRoot.querySelector('button');
|
|
182
|
+
expect(button.textContent).to.contain('Click me');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('reflects disabled attribute', async () => {
|
|
186
|
+
const el = await fixture(html`<app-button disabled></app-button>`);
|
|
187
|
+
const button = el.shadowRoot.querySelector('button');
|
|
188
|
+
expect(button.hasAttribute('disabled')).to.be.true;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('dispatches click event', async () => {
|
|
192
|
+
const el = await fixture(html`<app-button label="Go"></app-button>`);
|
|
193
|
+
let clicked = false;
|
|
194
|
+
el.addEventListener('click', () => { clicked = true; });
|
|
195
|
+
el.shadowRoot.querySelector('button').click();
|
|
196
|
+
expect(clicked).to.be.true;
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Store Integration Tests (browser)
|
|
202
|
+
|
|
203
|
+
For components that bind to the store, test the full cycle:
|
|
204
|
+
|
|
205
|
+
```javascript
|
|
206
|
+
// src/components/organisms/task-list/task-list.test.js
|
|
207
|
+
import { fixture, html, expect, waitUntil } from '@open-wc/testing';
|
|
208
|
+
import { store } from 'hybrids';
|
|
209
|
+
import './task-list.js';
|
|
210
|
+
|
|
211
|
+
describe('task-list', () => {
|
|
212
|
+
it('renders tasks from store', async () => {
|
|
213
|
+
const el = await fixture(html`<task-list project-id="1"></task-list>`);
|
|
214
|
+
await waitUntil(() => el.shadowRoot.querySelectorAll('task-card').length > 0);
|
|
215
|
+
const cards = el.shadowRoot.querySelectorAll('task-card');
|
|
216
|
+
expect(cards.length).to.be.greaterThan(0);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Configuration
|
|
224
|
+
|
|
225
|
+
### web-test-runner.config.js
|
|
226
|
+
|
|
227
|
+
```javascript
|
|
228
|
+
import { playwrightLauncher } from '@web/test-runner-playwright';
|
|
229
|
+
|
|
230
|
+
export default {
|
|
231
|
+
files: 'src/components/**/*.test.js',
|
|
232
|
+
nodeResolve: true,
|
|
233
|
+
browsers: [
|
|
234
|
+
playwrightLauncher({ product: 'chromium' }),
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### package.json Scripts
|
|
240
|
+
|
|
241
|
+
```json
|
|
242
|
+
{
|
|
243
|
+
"scripts": {
|
|
244
|
+
"test": "npm run test:node && npm run test:browser",
|
|
245
|
+
"test:node": "node --test 'src/**/*.test.js' 'server.test.js'",
|
|
246
|
+
"test:browser": "web-test-runner",
|
|
247
|
+
"test:watch": "web-test-runner --watch"
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Build-Phase Test Checkpoints
|
|
255
|
+
|
|
256
|
+
Each implementation phase must pass its tests before proceeding:
|
|
257
|
+
|
|
258
|
+
| Phase | Implement | Then test |
|
|
259
|
+
|---|---|---|
|
|
260
|
+
| 1. Infrastructure | server, vendor script, index.html | Server starts, routes respond, vendor files exist |
|
|
261
|
+
| 2. Store + Utils | models, formatDate, realtimeSync | Model shapes, util outputs, localStorage round-trip |
|
|
262
|
+
| 3. Atoms | app-button, app-badge, app-icon | Render, props, events |
|
|
263
|
+
| 4. Molecules | task-card, project-card | Composition, slot content |
|
|
264
|
+
| 5. Organisms | task-list, project-header | Store binding, data rendering |
|
|
265
|
+
| 6. Pages + Router | views, app-router | Navigation, full page render |
|
|
266
|
+
|
|
267
|
+
**Rule: never skip a checkpoint.** If phase 3 tests fail, fix before
|
|
268
|
+
starting phase 4. Bugs compound; catch them at the boundary.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>{{name}}</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/styles/reset.css">
|
|
8
|
+
<link rel="stylesheet" href="/src/styles/tokens.css">
|
|
9
|
+
<link rel="stylesheet" href="/src/styles/shared.css">
|
|
10
|
+
<link rel="stylesheet" href="/src/styles/buttons.css">
|
|
11
|
+
<link rel="stylesheet" href="/src/styles/forms.css">
|
|
12
|
+
<link rel="stylesheet" href="/src/styles/components.css">
|
|
13
|
+
|
|
14
|
+
<script type="importmap">
|
|
15
|
+
{
|
|
16
|
+
"imports": {
|
|
17
|
+
"hybrids": "/vendor/hybrids/index.js"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
<app-router></app-router>
|
|
24
|
+
<script type="module" src="/src/router/index.js"></script>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|