@invisibleloop/pulse 0.7.2 → 0.7.3
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 +1 @@
|
|
|
1
|
-
0.7.
|
|
1
|
+
0.7.2
|
package/docs/public/menu.js
CHANGED
|
@@ -3,62 +3,63 @@
|
|
|
3
3
|
* Loaded as an external script so it passes the CSP (no unsafe-inline needed).
|
|
4
4
|
*/
|
|
5
5
|
(function () {
|
|
6
|
-
// ──
|
|
7
|
-
var btn = document.querySelector('.mobile-menu-btn')
|
|
8
|
-
var sidebar = document.querySelector('.docs-sidebar')
|
|
9
|
-
var overlay = document.querySelector('.sidebar-overlay')
|
|
6
|
+
// ── Scroll active nav item into view ─────────────────────────────────────
|
|
10
7
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
function centerNavLink(sidebar, link) {
|
|
9
|
+
if (!sidebar || !link) return
|
|
10
|
+
var navRect = sidebar.getBoundingClientRect()
|
|
11
|
+
var linkRect = link.getBoundingClientRect()
|
|
12
|
+
sidebar.scrollTop = sidebar.scrollTop + (linkRect.top - navRect.top) - sidebar.clientHeight / 2 + link.clientHeight / 2
|
|
13
|
+
}
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
document.body.style.overflow = ''
|
|
22
|
-
}
|
|
15
|
+
// ── Bind sidebar, mobile menu, and centre the active link ─────────────────
|
|
16
|
+
// Called on first load and after every client-side navigation, because
|
|
17
|
+
// #pulse-root is replaced on navigate — all element references go stale.
|
|
23
18
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
function init() {
|
|
20
|
+
var sidebar = document.querySelector('.docs-sidebar')
|
|
21
|
+
var btn = document.querySelector('.mobile-menu-btn')
|
|
22
|
+
var overlay = document.querySelector('.sidebar-overlay')
|
|
27
23
|
|
|
28
|
-
|
|
24
|
+
// Mobile open/close
|
|
25
|
+
if (btn && sidebar) {
|
|
26
|
+
function open() {
|
|
27
|
+
sidebar.classList.add('open')
|
|
28
|
+
overlay && overlay.classList.add('visible')
|
|
29
|
+
document.body.style.overflow = 'hidden'
|
|
30
|
+
}
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
function close() {
|
|
33
|
+
sidebar.classList.remove('open')
|
|
34
|
+
overlay && overlay.classList.remove('visible')
|
|
35
|
+
document.body.style.overflow = ''
|
|
36
|
+
}
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
btn.addEventListener('click', function () {
|
|
39
|
+
sidebar.classList.contains('open') ? close() : open()
|
|
40
|
+
})
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
if (!navSidebar || !link) return
|
|
40
|
-
var navRect = navSidebar.getBoundingClientRect()
|
|
41
|
-
var linkRect = link.getBoundingClientRect()
|
|
42
|
-
navSidebar.scrollTop = navSidebar.scrollTop + (linkRect.top - navRect.top) - navSidebar.clientHeight / 2 + link.clientHeight / 2
|
|
43
|
-
}
|
|
42
|
+
overlay && overlay.addEventListener('click', close)
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (document.readyState === 'complete') {
|
|
49
|
-
setTimeout(function () { centerNavLink(activeLink) }, 50)
|
|
50
|
-
} else {
|
|
51
|
-
window.addEventListener('load', function () { centerNavLink(activeLink) })
|
|
44
|
+
sidebar.querySelectorAll('.nav-link').forEach(function (link) {
|
|
45
|
+
link.addEventListener('click', close)
|
|
46
|
+
})
|
|
52
47
|
}
|
|
48
|
+
|
|
49
|
+
// Centre the active link
|
|
50
|
+
var activeLink = sidebar && sidebar.querySelector('.nav-link.active')
|
|
51
|
+
if (activeLink) centerNavLink(sidebar, activeLink)
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
// On initial load — wait for layout paint before scrolling
|
|
55
|
+
if (document.readyState === 'complete') {
|
|
56
|
+
setTimeout(init, 50)
|
|
57
|
+
} else {
|
|
58
|
+
window.addEventListener('load', init)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// After client-side navigation — re-bind everything against the fresh DOM
|
|
62
|
+
document.addEventListener('pulse:navigate', init)
|
|
62
63
|
|
|
63
64
|
// ── Slider fill ──────────────────────────────────────────────────────────
|
|
64
65
|
function updateSliderFill(input) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invisibleloop/pulse",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AI-first frontend framework. The spec is the source of truth.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"scripts": {
|
|
58
58
|
"dev": "node --watch src/cli/dev.js",
|
|
59
59
|
"build": "node scripts/build.js",
|
|
60
|
-
"test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js",
|
|
60
|
+
"test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js",
|
|
61
61
|
"typecheck": "tsc --noEmit",
|
|
62
62
|
"docs": "node --watch src/cli/dev.js --root docs --port 4000",
|
|
63
63
|
"docs:start": "node docs/server.js"
|
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.7.
|
|
1
|
+
0.7.3
|
package/src/runtime/navigate.js
CHANGED
|
@@ -104,10 +104,13 @@ export function initNavigation(root, mountFn) {
|
|
|
104
104
|
document.dispatchEvent(new CustomEvent('pulse:navigate'))
|
|
105
105
|
|
|
106
106
|
if (hydratePath && mountFn) {
|
|
107
|
+
const { default: spec } = await import(/* @vite-ignore */ hydratePath)
|
|
108
|
+
// A newer navigation may have started while the import was in flight —
|
|
109
|
+
// bail out rather than destroying the already-mounted next page.
|
|
110
|
+
if (controller.signal.aborted) return
|
|
107
111
|
currentMount?.destroy()
|
|
108
112
|
root.dataset.pulseMounted = '1'
|
|
109
113
|
window.__PULSE_SERVER__ = finalServerState
|
|
110
|
-
const { default: spec } = await import(/* @vite-ignore */ hydratePath)
|
|
111
114
|
if (spec) currentMount = mountFn(spec, root, finalServerState)
|
|
112
115
|
}
|
|
113
116
|
|
|
@@ -130,10 +133,11 @@ export function initNavigation(root, mountFn) {
|
|
|
130
133
|
if (push) history.pushState({ pulse: true, path }, '', path)
|
|
131
134
|
|
|
132
135
|
if (hydrate && mountFn) {
|
|
136
|
+
const { default: spec } = await import(/* @vite-ignore */ hydrate)
|
|
137
|
+
if (controller.signal.aborted) return
|
|
133
138
|
currentMount?.destroy()
|
|
134
139
|
root.dataset.pulseMounted = '1'
|
|
135
140
|
window.__PULSE_SERVER__ = serverState || {}
|
|
136
|
-
const { default: spec } = await import(/* @vite-ignore */ hydrate)
|
|
137
141
|
if (spec) currentMount = mountFn(spec, root, serverState || {})
|
|
138
142
|
}
|
|
139
143
|
|
|
@@ -0,0 +1,304 @@
|
|
|
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)
|