@bespokeagentics/microdots-host 0.1.0 → 0.1.2
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/evals/topology-regressions.p5.v1.json +105 -0
- package/package.json +6 -4
- package/src/composeFromSelection.test.ts +277 -0
- package/src/composeFromSelection.ts +376 -0
- package/src/compositionSpec.test.ts +311 -0
- package/src/compositionSpec.ts +802 -0
- package/src/compositionSpec.v2.test.ts +216 -0
- package/src/fillComposition.test.ts +240 -0
- package/src/fillComposition.ts +159 -0
- package/src/index.ts +140 -2
- package/src/mountedRegistry.test.ts +196 -0
- package/src/mounting.test.ts +8 -8
- package/src/mounting.ts +116 -0
- package/src/placementChecks.test.ts +12 -12
- package/src/rules.test.ts +4 -4
- package/src/rules.ts +1 -1
- package/src/slotDom.test.ts +151 -0
- package/src/slotDom.ts +111 -0
- package/src/slotResize.test.ts +392 -0
- package/src/slotResize.ts +367 -0
- package/src/slots.test.ts +53 -0
- package/src/slots.ts +30 -3
- package/src/topologyRegression.test.ts +37 -0
- package/src/topologyRegression.ts +143 -0
- package/src/topologySource.test.ts +137 -0
- package/src/topologySource.ts +97 -0
- package/src/tracePublisher.test.ts +95 -0
- package/src/tracePublisher.ts +117 -0
- package/src/wire.test.ts +13 -11
- package/src/wireEngine.test.ts +60 -36
- package/src/wireEngine.ts +13 -3
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { TopologyUnavailable, acquireTopology } from './topologySource.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The runtime acquisition Phase A introduced, and specifically its FAILURE
|
|
7
|
+
* shapes — which is the whole reason this module exists as more than a `fetch`.
|
|
8
|
+
*
|
|
9
|
+
* Both shells used to decode at module scope, where a malformed topology threw
|
|
10
|
+
* before a line of the shell ran. Moving that to runtime traded a loud import
|
|
11
|
+
* error for a rejected promise, so every way this can go wrong now has to be
|
|
12
|
+
* distinguishable by a caller that wants to RENDER it. A version of this module
|
|
13
|
+
* that returned `undefined`, or an empty topology, on any of the four paths
|
|
14
|
+
* below would produce a blank page with a clean console — this repo's signature
|
|
15
|
+
* failure mode — and every assertion here exists to prevent that regression.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const VALID = {
|
|
19
|
+
host: { id: 'test-host', label: 'Test host', ownedInputs: [] },
|
|
20
|
+
routes: [
|
|
21
|
+
{
|
|
22
|
+
path: '/readout',
|
|
23
|
+
label: 'Readout',
|
|
24
|
+
title: 'Readout',
|
|
25
|
+
sectionIds: ['section-readout'],
|
|
26
|
+
mounts: [{ tag: 'readout-view', slotId: 'readout-slot' }],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
wires: [],
|
|
30
|
+
watch: [],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Only the CALLABLE half of `fetch`, deliberately: Bun's lib adds
|
|
35
|
+
* `fetch.preconnect`, so `typeof globalThis.fetch` demands a property no stub
|
|
36
|
+
* here needs and none of these tests exercise.
|
|
37
|
+
*/
|
|
38
|
+
type FetchStub = (input?: unknown, init?: RequestInit) => Promise<Response>
|
|
39
|
+
|
|
40
|
+
const stubFetch = (impl: FetchStub): void => {
|
|
41
|
+
vi.stubGlobal('fetch', impl)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const jsonResponse = (body: unknown): Response =>
|
|
45
|
+
new Response(JSON.stringify(body), {
|
|
46
|
+
status: 200,
|
|
47
|
+
headers: { 'content-type': 'application/json' },
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
vi.unstubAllGlobals()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('acquireTopology', () => {
|
|
55
|
+
test('decodes a well-formed document', async () => {
|
|
56
|
+
stubFetch(() => Promise.resolve(jsonResponse(VALID)))
|
|
57
|
+
|
|
58
|
+
const topology = await acquireTopology('/host-topology.json')
|
|
59
|
+
|
|
60
|
+
expect(topology.host.id).toBe('test-host')
|
|
61
|
+
expect(topology.routes[0].path).toBe('/readout')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('never caches — a shell that re-reads a cached copy reports the placement it booted with forever', async () => {
|
|
65
|
+
const seen: Array<RequestInit | undefined> = []
|
|
66
|
+
stubFetch((_input, init) => {
|
|
67
|
+
seen.push(init)
|
|
68
|
+
return Promise.resolve(jsonResponse(VALID))
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
await acquireTopology('/host-topology.json')
|
|
72
|
+
|
|
73
|
+
expect(seen[0]?.cache).toBe('no-store')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('a non-2xx response is unreachable, not an empty topology', async () => {
|
|
77
|
+
stubFetch(() =>
|
|
78
|
+
Promise.resolve(
|
|
79
|
+
new Response('nope', { status: 404, statusText: 'Not Found' }),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const error = await acquireTopology('/gone.json').catch(e => e)
|
|
84
|
+
|
|
85
|
+
expect(error).toBeInstanceOf(TopologyUnavailable)
|
|
86
|
+
expect(error.reason).toBe('unreachable')
|
|
87
|
+
expect(error.url).toBe('/gone.json')
|
|
88
|
+
expect(error.message).toContain('404')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('a fetch that throws is unreachable', async () => {
|
|
92
|
+
stubFetch(() => Promise.reject(new Error('offline')))
|
|
93
|
+
|
|
94
|
+
const error = await acquireTopology('/host-topology.json').catch(e => e)
|
|
95
|
+
|
|
96
|
+
expect(error).toBeInstanceOf(TopologyUnavailable)
|
|
97
|
+
expect(error.reason).toBe('unreachable')
|
|
98
|
+
expect(error.message).toContain('offline')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('a body that is not JSON is undecodable', async () => {
|
|
102
|
+
stubFetch(() =>
|
|
103
|
+
Promise.resolve(
|
|
104
|
+
new Response('<!doctype html><title>a login page</title>', {
|
|
105
|
+
status: 200,
|
|
106
|
+
headers: { 'content-type': 'text/html' },
|
|
107
|
+
}),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const error = await acquireTopology('/host-topology.json').catch(e => e)
|
|
112
|
+
|
|
113
|
+
expect(error).toBeInstanceOf(TopologyUnavailable)
|
|
114
|
+
expect(error.reason).toBe('undecodable')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('valid JSON that is not a topology is undecodable — the case module-scope decoding used to catch at import', async () => {
|
|
118
|
+
stubFetch(() =>
|
|
119
|
+
Promise.resolve(jsonResponse({ definitely: 'not a topology' })),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const error = await acquireTopology('/host-topology.json').catch(e => e)
|
|
123
|
+
|
|
124
|
+
expect(error).toBeInstanceOf(TopologyUnavailable)
|
|
125
|
+
expect(error.reason).toBe('undecodable')
|
|
126
|
+
expect(error.message).toContain('not a valid host topology')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('an EMPTY routes array is refused — `routes` is NonEmptyArray so a fallback always exists', async () => {
|
|
130
|
+
stubFetch(() => Promise.resolve(jsonResponse({ ...VALID, routes: [] })))
|
|
131
|
+
|
|
132
|
+
const error = await acquireTopology('/host-topology.json').catch(e => e)
|
|
133
|
+
|
|
134
|
+
expect(error).toBeInstanceOf(TopologyUnavailable)
|
|
135
|
+
expect(error.reason).toBe('undecodable')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Schema as S } from 'effect'
|
|
2
|
+
|
|
3
|
+
import { HostTopology } from './wire.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Acquiring a host's topology at RUNTIME instead of importing it at build.
|
|
7
|
+
*
|
|
8
|
+
* Both shells used to open with
|
|
9
|
+
* `const topology = S.decodeUnknownSync(HostTopology)(topologyJson)` at module
|
|
10
|
+
* scope, and that decode was deliberately loud: a malformed topology failed the
|
|
11
|
+
* page at startup rather than routing to a blank section at click time. A
|
|
12
|
+
* fetched document cannot fail that way — the module imports fine and the
|
|
13
|
+
* failure arrives later, on a promise — so the loudness has to be re-paid by
|
|
14
|
+
* the caller as a RENDERED state. That is why this module throws a typed error
|
|
15
|
+
* carrying a `reason` instead of returning `undefined` or an empty topology:
|
|
16
|
+
* an empty success is the shape that produces a blank page with a clean
|
|
17
|
+
* console, which is this repo's signature failure mode.
|
|
18
|
+
*
|
|
19
|
+
* See `wiki/plans/active/wiring-live-placement-on-a-remote-host.md` §The spine,
|
|
20
|
+
* Phase A. Nothing here polls; a caller re-reads by calling again.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Why a topology could not be produced. The two cases read differently to a human. */
|
|
24
|
+
export type TopologyFailureReason =
|
|
25
|
+
/** The document never arrived: offline, wrong URL, 404, CORS, 500. */
|
|
26
|
+
| 'unreachable'
|
|
27
|
+
/** The document arrived and is not a topology: bad JSON, or it failed the schema. */
|
|
28
|
+
| 'undecodable'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The declared failure. An `Error` subclass rather than a tagged union because
|
|
32
|
+
* every caller here is plain async/await — `loadMicroDot` and `mountMicroDot`
|
|
33
|
+
* throw too — and a shell's boot path already catches to render.
|
|
34
|
+
*/
|
|
35
|
+
export class TopologyUnavailable extends Error {
|
|
36
|
+
readonly reason: TopologyFailureReason
|
|
37
|
+
readonly url: string
|
|
38
|
+
readonly detail: string
|
|
39
|
+
|
|
40
|
+
constructor(reason: TopologyFailureReason, url: string, detail: string) {
|
|
41
|
+
super(
|
|
42
|
+
reason === 'unreachable'
|
|
43
|
+
? `The topology at ${url} could not be loaded: ${detail}`
|
|
44
|
+
: `The topology at ${url} is not a valid host topology: ${detail}`,
|
|
45
|
+
)
|
|
46
|
+
this.name = 'TopologyUnavailable'
|
|
47
|
+
this.reason = reason
|
|
48
|
+
this.url = url
|
|
49
|
+
this.detail = detail
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Hoisted once — the v4 idiom this repo uses for every decoder. */
|
|
54
|
+
const decodeTopology = S.decodeUnknownSync(HostTopology)
|
|
55
|
+
|
|
56
|
+
const describe = (error: unknown): string =>
|
|
57
|
+
error instanceof Error ? error.message : String(error)
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Fetch one topology document and decode it, or throw {@link TopologyUnavailable}.
|
|
61
|
+
*
|
|
62
|
+
* `cache: 'no-store'` because the whole point of moving this to runtime is that
|
|
63
|
+
* the answer changes: a shell that re-reads a cached copy would report the
|
|
64
|
+
* placement it booted with forever, which looks exactly like the feature not
|
|
65
|
+
* working.
|
|
66
|
+
*/
|
|
67
|
+
export const acquireTopology = async (
|
|
68
|
+
url: string,
|
|
69
|
+
): Promise<typeof HostTopology.Type> => {
|
|
70
|
+
let response: Response
|
|
71
|
+
try {
|
|
72
|
+
response = await fetch(url, { cache: 'no-store' })
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new TopologyUnavailable('unreachable', url, describe(error))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
throw new TopologyUnavailable(
|
|
79
|
+
'unreachable',
|
|
80
|
+
url,
|
|
81
|
+
`${String(response.status)} ${response.statusText}`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let raw: unknown
|
|
86
|
+
try {
|
|
87
|
+
raw = await response.json()
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new TopologyUnavailable('undecodable', url, describe(error))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
return decodeTopology(raw)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new TopologyUnavailable('undecodable', url, describe(error))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { postWireTraces, startTracePublisher } from './tracePublisher.ts'
|
|
4
|
+
import type { WireTrace } from './wireEngine.ts'
|
|
5
|
+
|
|
6
|
+
type FetchStub = (input?: unknown, init?: RequestInit) => Promise<Response>
|
|
7
|
+
|
|
8
|
+
const stubFetch = (impl: FetchStub): void => {
|
|
9
|
+
vi.stubGlobal('fetch', impl)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.unstubAllGlobals()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const written: WireTrace = {
|
|
17
|
+
_tag: 'wire',
|
|
18
|
+
event: 'quote-changed',
|
|
19
|
+
wireId: 'w2',
|
|
20
|
+
delivery: {
|
|
21
|
+
_tag: 'written',
|
|
22
|
+
write: { toTag: 'roster-list', input: 'region', value: 'us' },
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('postWireTraces', () => {
|
|
27
|
+
test('no-ops when url, key, or hostId is empty — fail closed, no fetch', async () => {
|
|
28
|
+
const seen: unknown[] = []
|
|
29
|
+
stubFetch(input => {
|
|
30
|
+
seen.push(input)
|
|
31
|
+
return Promise.resolve(new Response(null, { status: 200 }))
|
|
32
|
+
})
|
|
33
|
+
await postWireTraces({
|
|
34
|
+
url: '',
|
|
35
|
+
hostId: 'demo-host',
|
|
36
|
+
key: 'secret',
|
|
37
|
+
traces: [{ at: 1, heard: written }],
|
|
38
|
+
})
|
|
39
|
+
await postWireTraces({
|
|
40
|
+
url: '/traces',
|
|
41
|
+
hostId: 'demo-host',
|
|
42
|
+
key: '',
|
|
43
|
+
traces: [{ at: 1, heard: written }],
|
|
44
|
+
})
|
|
45
|
+
expect(seen).toHaveLength(0)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('POSTs JSON with no-store', async () => {
|
|
49
|
+
const seen: Array<{ input: unknown; init: RequestInit | undefined }> = []
|
|
50
|
+
stubFetch((input, init) => {
|
|
51
|
+
seen.push({ input, init })
|
|
52
|
+
return Promise.resolve(new Response(null, { status: 200 }))
|
|
53
|
+
})
|
|
54
|
+
await postWireTraces({
|
|
55
|
+
url: 'http://localhost:3107/traces',
|
|
56
|
+
hostId: 'demo-host',
|
|
57
|
+
key: 'secret',
|
|
58
|
+
traces: [{ at: 1, heard: written }],
|
|
59
|
+
})
|
|
60
|
+
expect(seen).toHaveLength(1)
|
|
61
|
+
expect(seen[0]?.init?.method).toBe('POST')
|
|
62
|
+
expect(seen[0]?.init?.cache).toBe('no-store')
|
|
63
|
+
expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({
|
|
64
|
+
hostId: 'demo-host',
|
|
65
|
+
key: 'secret',
|
|
66
|
+
traces: [{ at: 1, heard: written }],
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('startTracePublisher', () => {
|
|
72
|
+
test('batches publishes onto one POST', async () => {
|
|
73
|
+
vi.useFakeTimers()
|
|
74
|
+
const seen: unknown[] = []
|
|
75
|
+
stubFetch((_input, init) => {
|
|
76
|
+
seen.push(init?.body)
|
|
77
|
+
return Promise.resolve(new Response(null, { status: 200 }))
|
|
78
|
+
})
|
|
79
|
+
const publisher = startTracePublisher({
|
|
80
|
+
url: 'http://localhost:3107/traces',
|
|
81
|
+
hostId: 'demo-host',
|
|
82
|
+
key: 'secret',
|
|
83
|
+
intervalMs: 50,
|
|
84
|
+
})
|
|
85
|
+
publisher.publish(written)
|
|
86
|
+
publisher.publish(written)
|
|
87
|
+
expect(seen).toHaveLength(0)
|
|
88
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
89
|
+
expect(seen).toHaveLength(1)
|
|
90
|
+
const body: unknown = JSON.parse(String(seen[0]))
|
|
91
|
+
expect(body).toMatchObject({ hostId: 'demo-host', traces: [{}, {}] })
|
|
92
|
+
publisher.stop()
|
|
93
|
+
vi.useRealTimers()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { WireTrace } from './wireEngine.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Posting this page's wire-engine traces to a host-document URL
|
|
5
|
+
* (`POST /traces`), so a Wiring screen on another origin can Watch them.
|
|
6
|
+
*
|
|
7
|
+
* Lives here, not in a MicroDot: the host imports no MicroDot code. The body
|
|
8
|
+
* is plain JSON matching wiring's `RecordTracesInput`. Empty `url` or `key`
|
|
9
|
+
* is a silent no-op — fail closed, never a public dump, never a thrown boot.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type PostedWireTrace = {
|
|
13
|
+
readonly at: number
|
|
14
|
+
readonly heard: WireTrace
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type PostWireTracesOptions = {
|
|
18
|
+
readonly url: string
|
|
19
|
+
readonly hostId: string
|
|
20
|
+
readonly key: string
|
|
21
|
+
readonly traces: ReadonlyArray<PostedWireTrace>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const serialisable = (
|
|
25
|
+
traces: ReadonlyArray<PostedWireTrace>,
|
|
26
|
+
): ReadonlyArray<PostedWireTrace> =>
|
|
27
|
+
traces.filter(trace => {
|
|
28
|
+
try {
|
|
29
|
+
JSON.stringify(trace.heard)
|
|
30
|
+
return true
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One POST. Callers that cannot import the wiring contract use this.
|
|
38
|
+
* Failures are swallowed — a down ring must not take the host down with it.
|
|
39
|
+
*/
|
|
40
|
+
export const postWireTraces = async (
|
|
41
|
+
options: PostWireTracesOptions,
|
|
42
|
+
): Promise<void> => {
|
|
43
|
+
if (options.url === '' || options.key === '' || options.hostId === '') return
|
|
44
|
+
const traces = serialisable(options.traces)
|
|
45
|
+
if (traces.length === 0) return
|
|
46
|
+
try {
|
|
47
|
+
await fetch(options.url, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
cache: 'no-store',
|
|
50
|
+
headers: { 'content-type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
hostId: options.hostId,
|
|
53
|
+
key: options.key,
|
|
54
|
+
traces,
|
|
55
|
+
}),
|
|
56
|
+
})
|
|
57
|
+
} catch {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type TracePublisher = {
|
|
63
|
+
readonly publish: (trace: WireTrace) => void
|
|
64
|
+
readonly stop: () => void
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type TracePublisherOptions = {
|
|
68
|
+
readonly url: string
|
|
69
|
+
readonly hostId: string
|
|
70
|
+
readonly key: string
|
|
71
|
+
readonly intervalMs?: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Batches `onTrace` firings and POSTs them. A live host can fire many wires
|
|
76
|
+
* on one event; one POST per firing would be a denial of the ring.
|
|
77
|
+
*/
|
|
78
|
+
export const startTracePublisher = (
|
|
79
|
+
options: TracePublisherOptions,
|
|
80
|
+
): TracePublisher => {
|
|
81
|
+
const intervalMs = options.intervalMs ?? 200
|
|
82
|
+
const pending: Array<PostedWireTrace> = []
|
|
83
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
84
|
+
let stopped = false
|
|
85
|
+
|
|
86
|
+
const flush = (): void => {
|
|
87
|
+
timer = undefined
|
|
88
|
+
if (stopped || pending.length === 0) return
|
|
89
|
+
const batch = pending.splice(0, pending.length)
|
|
90
|
+
void postWireTraces({
|
|
91
|
+
url: options.url,
|
|
92
|
+
hostId: options.hostId,
|
|
93
|
+
key: options.key,
|
|
94
|
+
traces: batch,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
publish: trace => {
|
|
100
|
+
if (stopped) return
|
|
101
|
+
if (options.url === '' || options.key === '' || options.hostId === '') {
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
pending.push({ at: Date.now(), heard: trace })
|
|
105
|
+
if (timer !== undefined) return
|
|
106
|
+
timer = setTimeout(flush, intervalMs)
|
|
107
|
+
},
|
|
108
|
+
stop: () => {
|
|
109
|
+
stopped = true
|
|
110
|
+
if (timer !== undefined) {
|
|
111
|
+
clearTimeout(timer)
|
|
112
|
+
timer = undefined
|
|
113
|
+
}
|
|
114
|
+
pending.length = 0
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/wire.test.ts
CHANGED
|
@@ -62,7 +62,7 @@ const manifestEvent = (
|
|
|
62
62
|
})
|
|
63
63
|
|
|
64
64
|
const sourceTag = (event: ManifestEvent): ManifestTag => ({
|
|
65
|
-
tag: '
|
|
65
|
+
tag: 'readout-view',
|
|
66
66
|
attributes: [],
|
|
67
67
|
events: [event],
|
|
68
68
|
})
|
|
@@ -87,11 +87,11 @@ const tableOf = (
|
|
|
87
87
|
): RouteTable => ({ routes: [first, ...rest], fallback: first })
|
|
88
88
|
|
|
89
89
|
/** Both ends on one route: the placement half of `live`. */
|
|
90
|
-
const SHARED_TABLE = tableOf(route('/all', ['
|
|
90
|
+
const SHARED_TABLE = tableOf(route('/all', ['readout-view', 'fleet-health']))
|
|
91
91
|
|
|
92
92
|
/** Both ends placed, but never together. */
|
|
93
93
|
const SPLIT_TABLE = tableOf(
|
|
94
|
-
route('/price', ['
|
|
94
|
+
route('/price', ['readout-view']),
|
|
95
95
|
route('/fleet', ['fleet-health']),
|
|
96
96
|
)
|
|
97
97
|
|
|
@@ -102,7 +102,7 @@ const STRING_MANIFESTS: ReadonlyArray<ManifestTag> = [
|
|
|
102
102
|
|
|
103
103
|
const directWire: Wire = {
|
|
104
104
|
id: 'w1',
|
|
105
|
-
from: '
|
|
105
|
+
from: 'readout-view',
|
|
106
106
|
event: 'quote-changed',
|
|
107
107
|
field: 'symbol',
|
|
108
108
|
fieldType: 'string',
|
|
@@ -355,7 +355,7 @@ describe('deriveWireState', () => {
|
|
|
355
355
|
topologyRouteTable
|
|
356
356
|
============================================================ */
|
|
357
357
|
|
|
358
|
-
const PRICE_ROUTE = route('/price', ['
|
|
358
|
+
const PRICE_ROUTE = route('/price', ['readout-view'])
|
|
359
359
|
const FLEET_ROUTE = route('/fleet', ['fleet-health'])
|
|
360
360
|
|
|
361
361
|
const topologyWith = (overview: HostTopology['overview']): HostTopology => ({
|
|
@@ -383,11 +383,11 @@ describe('topologyRouteTable', () => {
|
|
|
383
383
|
expect(table.fallback.path).toBe('/')
|
|
384
384
|
expect(table.fallback.sectionIds).toEqual([
|
|
385
385
|
'intro-section',
|
|
386
|
-
'
|
|
386
|
+
'readout-view-section',
|
|
387
387
|
'fleet-health-section',
|
|
388
388
|
])
|
|
389
389
|
expect(table.fallback.mounts.map(mount => mount.tag)).toEqual([
|
|
390
|
-
'
|
|
390
|
+
'readout-view',
|
|
391
391
|
'fleet-health',
|
|
392
392
|
])
|
|
393
393
|
// The derived overview leads the table, so `/` wins route matching.
|
|
@@ -430,7 +430,7 @@ describe('topologyRouteTable', () => {
|
|
|
430
430
|
// The fallback is a route too — a rule-carried tag must be findable there.
|
|
431
431
|
expect(table.fallback.mounts.map(mount => mount.tag)).toEqual([
|
|
432
432
|
'promo-banner',
|
|
433
|
-
'
|
|
433
|
+
'readout-view',
|
|
434
434
|
])
|
|
435
435
|
})
|
|
436
436
|
|
|
@@ -444,7 +444,7 @@ describe('topologyRouteTable', () => {
|
|
|
444
444
|
label: 'Everywhere',
|
|
445
445
|
pattern: '/*',
|
|
446
446
|
placements: [
|
|
447
|
-
{ tag: '
|
|
447
|
+
{ tag: 'readout-view', slotId: 'readout-view-slot' },
|
|
448
448
|
{ tag: 'fleet-health', slotId: 'fleet-health-slot' },
|
|
449
449
|
],
|
|
450
450
|
},
|
|
@@ -543,8 +543,10 @@ const topologyOnDisk = (relativePath: string): unknown =>
|
|
|
543
543
|
// decode, would change what every host mounts without either file being
|
|
544
544
|
// touched. `toEqual(Option.some(parsed))` pins the decode as the identity.
|
|
545
545
|
describe('the shipped host-topology.json files', () => {
|
|
546
|
-
test('the
|
|
547
|
-
const parsed = topologyOnDisk(
|
|
546
|
+
test('the pitch-deck host topology still decodes, unchanged', () => {
|
|
547
|
+
const parsed = topologyOnDisk(
|
|
548
|
+
'../../../apps/pitch-deck-website/host/host-topology.json',
|
|
549
|
+
)
|
|
548
550
|
expect(decodeTopology(parsed)).toEqual(Option.some(parsed))
|
|
549
551
|
})
|
|
550
552
|
|