@invisibleloop/pulse 0.20.37 → 0.20.39

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.
@@ -1,304 +0,0 @@
1
- /**
2
- * Pulse — Navigate tests
3
- * run: node src/runtime/navigate.test.js
4
- */
5
-
6
- import { initNavigation } from './navigate.js'
7
- import assert from 'node:assert'
8
-
9
- // ── Test runner ───────────────────────────────────────────────────────────────
10
-
11
- let passed = 0
12
- let failed = 0
13
-
14
- async function test(label, fn) {
15
- try {
16
- await fn()
17
- console.log(` ✓ ${label}`)
18
- passed++
19
- } catch (e) {
20
- console.log(` ✗ ${label}`)
21
- console.log(` ${e.message}`)
22
- failed++
23
- }
24
- }
25
-
26
- // ── Minimal browser shims ─────────────────────────────────────────────────────
27
-
28
- function makeRoot() {
29
- const root = {
30
- innerHTML: '',
31
- dataset: {},
32
- querySelector: () => null,
33
- querySelectorAll: () => [],
34
- hasAttribute: (k) => root._attrs.has(k),
35
- setAttribute: (k) => root._attrs.add(k),
36
- removeAttribute: (k) => root._attrs.delete(k),
37
- addEventListener: () => {},
38
- focus: () => {},
39
- _attrs: new Set(),
40
- }
41
- return root
42
- }
43
-
44
- /**
45
- * Set up fresh browser globals and call initNavigation().
46
- * Returns helpers to fire the registered click and popstate handlers.
47
- */
48
- function setup({ mountFn = null } = {}) {
49
- let clickHandler = null
50
- let popHandler = null
51
-
52
- global.history = { replaceState: () => {}, pushState: () => {} }
53
- global.location = { pathname: '/start', search: '', origin: 'http://localhost', href: '' }
54
- global.window = {
55
- scrollTo: () => {},
56
- addEventListener: (e, fn) => { if (e === 'popstate') popHandler = fn },
57
- }
58
- global.document = {
59
- title: 'Test',
60
- addEventListener: (e, fn) => { if (e === 'click') clickHandler = fn },
61
- dispatchEvent: () => {},
62
- querySelectorAll: () => [],
63
- createElement: () => ({
64
- rel: '', href: '', src: '', textContent: '', type: '',
65
- setAttribute: () => {},
66
- getAttribute: () => null,
67
- onload: null, onerror: null,
68
- }),
69
- head: { appendChild: () => {} },
70
- }
71
- global.CustomEvent = class { constructor(n) { this.type = n } }
72
-
73
- const root = makeRoot()
74
- initNavigation(root, mountFn)
75
-
76
- return {
77
- root,
78
- click: (ev) => clickHandler(ev),
79
- pop: (ev) => popHandler(ev),
80
- }
81
- }
82
-
83
- /** Build a synthetic click event targeting an anchor. */
84
- function clickEvent({ href, ctrlKey = false, metaKey = false, shiftKey = false, altKey = false, linkTarget = '' }) {
85
- const anchor = {
86
- getAttribute: (k) => k === 'href' ? (href ?? null) : null,
87
- target: linkTarget,
88
- }
89
- return {
90
- target: { closest: (sel) => sel === 'a' ? anchor : null },
91
- ctrlKey, metaKey, shiftKey, altKey,
92
- preventDefault: () => {},
93
- }
94
- }
95
-
96
- /** Event where target has no ancestor <a>. */
97
- function nonAnchorEvent() {
98
- return {
99
- target: { closest: () => null },
100
- ctrlKey: false, metaKey: false, shiftKey: false, altKey: false,
101
- preventDefault: () => {},
102
- }
103
- }
104
-
105
- // ── Click handler — link filtering ───────────────────────────────────────────
106
-
107
- console.log('\nClick handler — link filtering\n')
108
-
109
- await test('does not intercept clicks with no ancestor anchor', async () => {
110
- const { click } = setup()
111
- let called = false
112
- global.fetch = async () => { called = true; return new Promise(() => {}) }
113
- click(nonAnchorEvent())
114
- assert(!called)
115
- })
116
-
117
- await test('does not intercept anchor with no href', async () => {
118
- const { click } = setup()
119
- let called = false
120
- global.fetch = async () => { called = true; return new Promise(() => {}) }
121
- click(clickEvent({ href: null }))
122
- assert(!called)
123
- })
124
-
125
- await test('does not intercept cross-origin links', async () => {
126
- const { click } = setup()
127
- let called = false
128
- global.fetch = async () => { called = true; return new Promise(() => {}) }
129
- click(clickEvent({ href: 'https://example.com/page' }))
130
- assert(!called)
131
- })
132
-
133
- await test('does not intercept ctrl+click', async () => {
134
- const { click } = setup()
135
- let called = false
136
- global.fetch = async () => { called = true; return new Promise(() => {}) }
137
- click(clickEvent({ href: '/page', ctrlKey: true }))
138
- assert(!called)
139
- })
140
-
141
- await test('does not intercept meta+click', async () => {
142
- const { click } = setup()
143
- let called = false
144
- global.fetch = async () => { called = true; return new Promise(() => {}) }
145
- click(clickEvent({ href: '/page', metaKey: true }))
146
- assert(!called)
147
- })
148
-
149
- await test('does not intercept shift+click', async () => {
150
- const { click } = setup()
151
- let called = false
152
- global.fetch = async () => { called = true; return new Promise(() => {}) }
153
- click(clickEvent({ href: '/page', shiftKey: true }))
154
- assert(!called)
155
- })
156
-
157
- await test('does not intercept target=_blank links', async () => {
158
- const { click } = setup()
159
- let called = false
160
- global.fetch = async () => { called = true; return new Promise(() => {}) }
161
- click(clickEvent({ href: '/page', linkTarget: '_blank' }))
162
- assert(!called)
163
- })
164
-
165
- await test('intercepts same-origin links', async () => {
166
- const { click } = setup()
167
- let called = false
168
- global.fetch = async () => { called = true; return new Promise(() => {}) }
169
- click(clickEvent({ href: '/same-origin' }))
170
- assert(called, 'fetch should be called for same-origin link')
171
- })
172
-
173
- // ── Navigation — fetch abort and cancellation ────────────────────────────────
174
-
175
- console.log('\nNavigation — fetch abort and cancellation\n')
176
-
177
- await test('aborts in-flight fetch when a new navigation starts', async () => {
178
- const { click } = setup()
179
- const signals = []
180
- global.fetch = async (_url, opts) => {
181
- signals.push(opts.signal)
182
- return new Promise(() => {}) // never resolves
183
- }
184
-
185
- click(clickEvent({ href: '/page-a' }))
186
- click(clickEvent({ href: '/page-b' }))
187
-
188
- assert.equal(signals.length, 2, 'two fetches made')
189
- assert(signals[0].aborted, 'first navigation signal should be aborted')
190
- assert(!signals[1].aborted, 'second navigation signal should not be aborted')
191
- })
192
-
193
- await test('swallows AbortError — does not fall back to location.href', async () => {
194
- const { click } = setup()
195
- global.fetch = async () => {
196
- const err = new Error('aborted')
197
- err.name = 'AbortError'
198
- throw err
199
- }
200
- let fallbackSet = false
201
- Object.defineProperty(global.location, 'href', {
202
- set: () => { fallbackSet = true },
203
- get: () => '',
204
- configurable: true,
205
- })
206
- click(clickEvent({ href: '/page' }))
207
- await new Promise(r => setTimeout(r, 10))
208
- assert(!fallbackSet, 'AbortError must not trigger location.href fallback')
209
- })
210
-
211
- await test('falls back to location.href on network error', async () => {
212
- const { click } = setup()
213
- global.fetch = async () => { throw new Error('network failure') }
214
- let fallback = null
215
- Object.defineProperty(global.location, 'href', {
216
- set: (v) => { fallback = v },
217
- get: () => '',
218
- configurable: true,
219
- })
220
- click(clickEvent({ href: '/broken' }))
221
- await new Promise(r => setTimeout(r, 10))
222
- assert.equal(fallback, '/broken')
223
- })
224
-
225
- await test('falls back to location.href on non-ok response', async () => {
226
- const { click } = setup()
227
- global.fetch = async () => ({ ok: false })
228
- let fallback = null
229
- Object.defineProperty(global.location, 'href', {
230
- set: (v) => { fallback = v },
231
- get: () => '',
232
- configurable: true,
233
- })
234
- click(clickEvent({ href: '/missing' }))
235
- await new Promise(r => setTimeout(r, 10))
236
- assert.equal(fallback, '/missing')
237
- })
238
-
239
- // ── Navigation — JSON response ───────────────────────────────────────────────
240
-
241
- console.log('\nNavigation — JSON response\n')
242
-
243
- function jsonFetch(payload) {
244
- return async () => ({
245
- ok: true,
246
- headers: { get: () => 'application/json' },
247
- json: async () => payload,
248
- })
249
- }
250
-
251
- await test('sets root.innerHTML from JSON html field', async () => {
252
- const { click, root } = setup()
253
- global.fetch = jsonFetch({ html: '<p>Hello</p>', title: 'T', styles: [], scripts: [], hydrate: null, serverState: {}, storeState: null })
254
- click(clickEvent({ href: '/new' }))
255
- await new Promise(r => setTimeout(r, 10))
256
- assert.equal(root.innerHTML, '<p>Hello</p>')
257
- })
258
-
259
- await test('updates document.title from JSON title field', async () => {
260
- const { click } = setup()
261
- global.fetch = jsonFetch({ html: '', title: 'New Title', styles: [], scripts: [], hydrate: null, serverState: {}, storeState: null })
262
- click(clickEvent({ href: '/titled' }))
263
- await new Promise(r => setTimeout(r, 10))
264
- assert.equal(global.document.title, 'New Title')
265
- })
266
-
267
- await test('updates store state when storeState is present', async () => {
268
- const { click } = setup()
269
- global.fetch = jsonFetch({ html: '', title: '', styles: [], scripts: [], hydrate: null, serverState: {}, storeState: { theme: 'dark' } })
270
- let stored = null
271
- global.window.__updatePulseStore__ = (s) => { stored = s }
272
- click(clickEvent({ href: '/store-page' }))
273
- await new Promise(r => setTimeout(r, 10))
274
- assert.deepEqual(stored, { theme: 'dark' })
275
- })
276
-
277
- // ── Navigation — popstate ────────────────────────────────────────────────────
278
-
279
- console.log('\nNavigation — popstate\n')
280
-
281
- await test('navigates on pulse popstate', async () => {
282
- const { pop } = setup()
283
- let fetchedPath = null
284
- global.fetch = async (path) => { fetchedPath = path; return new Promise(() => {}) }
285
- global.location.pathname = '/history-page'
286
- global.location.search = ''
287
- pop({ state: { pulse: true } })
288
- await new Promise(r => setTimeout(r, 0))
289
- assert.equal(fetchedPath, '/history-page')
290
- })
291
-
292
- await test('ignores popstate without pulse state', async () => {
293
- const { pop } = setup()
294
- let called = false
295
- global.fetch = async () => { called = true; return new Promise(() => {}) }
296
- pop({ state: null })
297
- pop({ state: { pulse: false } })
298
- assert(!called, 'fetch should not be called for non-pulse popstate')
299
- })
300
-
301
- // ── Summary ───────────────────────────────────────────────────────────────────
302
-
303
- console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
304
- if (failed > 0) process.exit(1)