@operato/layout 10.29.38 → 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.
- package/CHANGELOG.md +9 -0
- package/coverage/lcov-report/index.html +89 -89
- package/coverage/lcov.info +33927 -33126
- package/dist/src/actions/entity360-registry.js +15 -9
- package/dist/src/actions/entity360-registry.js.map +1 -1
- package/dist/src/actions/entity360.d.ts +1 -1
- package/dist/src/actions/entity360.js +3 -2
- package/dist/src/actions/entity360.js.map +1 -1
- package/dist/src/actions/layout.d.ts +1 -1
- package/dist/src/actions/layout.js +68 -31
- package/dist/src/actions/layout.js.map +1 -1
- package/dist/src/components/ox-url-tabs.d.ts +3 -2
- package/dist/src/components/ox-url-tabs.js +20 -10
- package/dist/src/components/ox-url-tabs.js.map +1 -1
- package/dist/src/initializer.js +5 -46
- package/dist/src/initializer.js.map +1 -1
- package/dist/test/address-sequences.test.d.ts +1 -0
- package/dist/test/address-sequences.test.js +190 -0
- package/dist/test/address-sequences.test.js.map +1 -0
- package/dist/test/entity360.test.js +3 -3
- package/dist/test/entity360.test.js.map +1 -1
- package/dist/test/history-retention.test.d.ts +1 -0
- package/dist/test/history-retention.test.js +25 -0
- package/dist/test/history-retention.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/address-sequences.test.ts +185 -0
- package/test/entity360.test.ts +3 -3
- package/test/history-retention.test.ts +25 -0
|
@@ -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
|
+
})
|
package/test/entity360.test.ts
CHANGED
|
@@ -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
|
+
})
|