@operato/layout 10.29.37 → 10.29.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.
@@ -0,0 +1,185 @@
1
+ import { expect, fixture } from '@open-wc/testing'
2
+ import { sendMouse } from '@web/test-runner-commands'
3
+ import { html } from 'lit'
4
+ import { addressController, writeAddress } from '@operato/utils/address.js'
5
+ import { closeOverlay, openPopup } from '../src/index.js'
6
+ import type { PopupHandle } from '@operato/popup'
7
+
8
+ const wait = () => new Promise(resolve => setTimeout(resolve, 10))
9
+ const navigation = () => (window as any).navigation
10
+
11
+ /** An independent product model: visible popups, the user's URL steps, and the cursor among them. */
12
+ class Expected {
13
+ entries: string[]
14
+ index = 0
15
+ popups: { at: number; below: number }[] = []
16
+ constructor(url: string) {
17
+ this.entries = [url]
18
+ }
19
+ write(url: string, replace = false) {
20
+ if (this.popups.length) return false
21
+ if (url === this.entries[this.index]) return true
22
+ if (replace) this.entries[this.index] = url
23
+ else {
24
+ this.entries.splice(++this.index)
25
+ this.entries.push(url)
26
+ }
27
+ return true
28
+ }
29
+ open() {
30
+ const below = this.index
31
+ const url = this.entries[this.index]
32
+ this.entries.splice(++this.index)
33
+ this.entries.push(url)
34
+ this.popups.push({ at: this.index, below })
35
+ }
36
+ close() {
37
+ const top = this.popups.pop()
38
+ if (top) this.index = top.below
39
+ }
40
+ move(delta: number) {
41
+ this.index += delta
42
+ this.popups = this.popups.filter(popup => popup.at <= this.index)
43
+ }
44
+ }
45
+
46
+ describe('address behaviour against an independent expected history', () => {
47
+ it('matches every step, including close followed immediately by a new committed query', async () => {
48
+ const events: unknown[] = []
49
+ let phase = 'setup'
50
+ const snapshot = (kind: string, details?: unknown) => events.push({
51
+ kind, phase, details, time: performance.now(), ready: document.readyState,
52
+ historyLength: history.length, activated: navigator.userActivation.hasBeenActive, current: navigation().currentEntry.key,
53
+ entries: navigation().entries().map((entry: any) => [entry.key, entry.index, new URL(entry.url).search])
54
+ })
55
+ const observed = new WeakSet<object>()
56
+ const observeEntries = () => navigation().entries().forEach((entry: any) => {
57
+ if (observed.has(entry)) return
58
+ observed.add(entry)
59
+ entry.addEventListener('dispose', () => snapshot('dispose', { key: entry.key, url: entry.url }))
60
+ })
61
+ const navigate = (event: any) => snapshot('navigate', { type: event.navigationType,
62
+ destination: event.destination.key, url: event.destination.url, cancelled: event.defaultPrevented })
63
+ const changed = (event: any) => {
64
+ snapshot('currententrychange', { type: event.navigationType, from: event.from.key })
65
+ observeEntries()
66
+ }
67
+ navigation().addEventListener('navigate', navigate)
68
+ navigation().addEventListener('currententrychange', changed)
69
+ const original = { pushState: history.pushState, replaceState: history.replaceState, traverseTo: navigation().traverseTo }
70
+ for (const method of ['pushState', 'replaceState'] as const) {
71
+ history[method] = function (...args: Parameters<History[typeof method]>) {
72
+ snapshot(method, { url: String(args[2]), stack: new Error().stack })
73
+ return original[method].apply(this, args)
74
+ }
75
+ }
76
+ navigation().traverseTo = function (...args: any[]) {
77
+ snapshot('traverseTo', { key: args[0] })
78
+ const result = original.traverseTo.apply(this, args)
79
+ for (const name of ['committed', 'finished']) result[name].then(
80
+ () => snapshot(name, { key: args[0] }),
81
+ (error: Error) => snapshot(name, { key: args[0], error: error.name }))
82
+ return result
83
+ }
84
+ observeEntries()
85
+ snapshot('start')
86
+ try {
87
+ // The oracle models user actions. At Chrome's 50-entry limit, an unactivated document's
88
+ // entries are preferentially pruned, including a recently pushed baseline. See the native
89
+ // no-app control in operato-application/test/address-browser/native-retention.cjs.
90
+ const start = await fixture<HTMLButtonElement>(html`<button>Start history sequence</button>`)
91
+ const bounds = start.getBoundingClientRect()
92
+ await sendMouse({ type: 'click', position: [Math.round(bounds.x + bounds.width / 2), Math.round(bounds.y + bounds.height / 2)] })
93
+ expect(navigator.userActivation.hasBeenActive, 'real user activation for the product oracle').to.equal(true)
94
+ start.remove()
95
+ await fixture(html`<ox-header-bar></ox-header-bar>`)
96
+ addressController().registerQuery()
97
+ for (let seed = 1; seed <= 10; seed++) {
98
+ let randomState = seed
99
+ const random = () => (randomState = (Math.imul(1664525, randomState) + 1013904223) >>> 0) / 2 ** 32
100
+ expect(await writeAddress(`?filters=seed-${seed}`)).to.equal(true)
101
+ let floorKey = navigation().currentEntry.key
102
+ const floor = () =>
103
+ navigation()
104
+ .entries()
105
+ .findIndex((entry: { key: string }) => entry.key === floorKey)
106
+ const expected = new Expected(location.href)
107
+ let handles: PopupHandle[] = []
108
+ const trace: string[] = []
109
+ const snapshots: unknown[] = []
110
+ for (let step = 0; step < 30; step++) {
111
+ const action = Math.floor(random() * 7)
112
+ phase = `seed ${seed} step ${step} action ${action}`
113
+ snapshot('action')
114
+ trace.push(String(action))
115
+ if (action === 0 || action === 1 || action === 6) {
116
+ if (action === 6 && handles.length) {
117
+ handles.pop()!.close()
118
+ expected.close()
119
+ }
120
+ const url = new URL(expected.entries[expected.index])
121
+ url.searchParams.set(action === 1 ? 'f' : 'filters', `${seed}-${step}`)
122
+ const replace = action === 1
123
+ const accepted = await writeAddress(url, { replace })
124
+ expect(accepted, `seed ${seed}, step ${step}`).to.equal(expected.write(url.href, replace))
125
+ if (accepted && replace && expected.index === 0) floorKey = navigation().currentEntry.key
126
+ } else if (action === 2 && handles.length < 2) {
127
+ handles.push(openPopup(html`<div>picker</div>`, { backdrop: true, escapable: true }))
128
+ expected.open()
129
+ } else if (action === 3 && handles.length) {
130
+ handles.pop()!.close()
131
+ expected.close()
132
+ } else if (action === 4 && expected.index > 0) {
133
+ expected.move(-1)
134
+ await navigation().back().finished
135
+ handles = handles.slice(0, expected.popups.length)
136
+ } else if (action === 5 && expected.index + 1 < expected.entries.length) {
137
+ expected.move(1)
138
+ await navigation().forward().finished
139
+ }
140
+ await addressController().whenSettled()
141
+ for (let attempt = 0; navigation().currentEntry.index !== floor() + expected.index; attempt++) {
142
+ if (attempt > 100) throw new Error(`did not settle: seed ${seed}, step ${step}, trace ${trace}`)
143
+ await wait()
144
+ }
145
+ await wait()
146
+ snapshots.push({ step, action, floorKey, current: navigation().currentEntry.key,
147
+ entries: navigation().entries().map((entry: any) => [entry.key, entry.index, new URL(entry.url).search]) })
148
+ expect(
149
+ floor(),
150
+ `sequence retained: seed ${seed}, step ${step}, snapshots ${JSON.stringify(snapshots)}, trace ${trace}, entries ${navigation()
151
+ .entries()
152
+ .map((entry: { url: string }) => new URL(entry.url).search)}`
153
+ ).to.be.at.least(0)
154
+ const actual = navigation()
155
+ .entries()
156
+ .slice(floor())
157
+ .map((entry: { url: string }) => entry.url)
158
+ const detail = `seed ${seed}, step ${step}, trace ${trace}`
159
+ expect(actual, detail).to.deep.equal(expected.entries)
160
+ expect(location.href, detail).to.equal(expected.entries[expected.index])
161
+ const bar = document.querySelector('ox-header-bar')!.shadowRoot!
162
+ expect(bar.querySelectorAll('ox-floating-overlay').length, detail).to.equal(expected.popups.length)
163
+ }
164
+ while (handles.length) {
165
+ closeOverlay(handles.pop()!.name)
166
+ expected.close()
167
+ await addressController().whenSettled()
168
+ for (let attempt = 0; navigation().currentEntry.index !== floor() + expected.index; attempt++) {
169
+ if (attempt > 100) throw new Error('popup cleanup did not settle')
170
+ await wait()
171
+ }
172
+ }
173
+ }
174
+ } catch (error) {
175
+ console.error('ADDRESS_HISTORY_TRACE', JSON.stringify(events))
176
+ throw error
177
+ } finally {
178
+ history.pushState = original.pushState
179
+ history.replaceState = original.replaceState
180
+ navigation().traverseTo = original.traverseTo
181
+ navigation().removeEventListener('navigate', navigate)
182
+ navigation().removeEventListener('currententrychange', changed)
183
+ }
184
+ }).timeout(30000)
185
+ })
@@ -112,17 +112,17 @@ describe('shared nonmodal entity 360 dock', () => {
112
112
  expect(dock()).to.exist
113
113
  })
114
114
 
115
- it('records distinct targets and preserves unrelated address state', () => {
115
+ it('records distinct targets and preserves unrelated address state', async () => {
116
116
  const initial = location.href
117
117
  history.replaceState({ userState: 42 }, '', initial)
118
118
  const first = new URL(initial)
119
119
  first.searchParams.set('f', 'testSpecifications:one')
120
120
  first.searchParams.set('revision', 'r1')
121
- navigateEntity360(first.href)
121
+ expect(await navigateEntity360(first.href)).to.equal(true)
122
122
  expect(canGoBackEntity360()).to.equal(false)
123
123
  const second = new URL(first)
124
124
  second.searchParams.set('revision', 'r2')
125
- navigateEntity360(second.href)
125
+ expect(await navigateEntity360(second.href)).to.equal(true)
126
126
  expect(canGoBackEntity360()).to.equal(true)
127
127
  expect(history.state.userState).to.equal(42)
128
128
  expect(history.state.entity360Previous).to.equal(first.href)
@@ -0,0 +1,25 @@
1
+ import { expect, fixture } from '@open-wc/testing'
2
+ import { html } from 'lit'
3
+ import { openPopup } from '../src/index.js'
4
+
5
+ const wait = () => new Promise(resolve => setTimeout(resolve, 30))
6
+
7
+ describe('popup identity when the browser prunes old history', () => {
8
+ it('closing a nested popup keeps its parent even when entry indices have shifted', async () => {
9
+ await fixture(html`<ox-header-bar></ox-header-bar>`)
10
+ // Chrome retains a bounded session history. New entries can renumber existing entries.
11
+ for (let i = 0; i < 70; i++) history.pushState({}, '', `?retention=${i}`)
12
+ const parent = openPopup(html`<div>parent</div>`, { backdrop: true })
13
+ await wait()
14
+ const child = openPopup(html`<div>child</div>`, { backdrop: true })
15
+ await wait()
16
+ child.close()
17
+ await child.result
18
+ await wait()
19
+ expect(parent.closed, 'the parent is still open').to.equal(false)
20
+ const overlays = document.querySelector('ox-header-bar')!.shadowRoot!.querySelectorAll('ox-floating-overlay')
21
+ expect(overlays.length).to.equal(1)
22
+ parent.close()
23
+ await parent.result
24
+ })
25
+ })