@kabmo-labs/kabmo 0.4.0 → 0.5.1
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/dist/client/index.html +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/static/js/{index-twsk4wfa.js → index-za950td7.js} +665 -665
- package/package.json +1 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -3
- package/src/client/components/layout/session-sidebar/Header.tsx +2 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -1
- package/src/client/components/mobile/LayoutWrapper.tsx +4 -0
- package/src/client/components/mobile/RightSidebar.tsx +23 -32
- package/src/client/components/mobile/SessionSidebar.tsx +120 -103
- package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +7 -4
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +6 -1
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
- package/src/client/components/mobile/panel-chips.test.tsx +155 -0
- package/src/client/components/mobile/panel-chips.tsx +74 -0
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +12 -4
- package/src/client/components/workspace/model-dropdown/index.tsx +3 -3
- package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
- package/src/client/hooks/chat/timeline/queue-rewind.test.ts +6 -0
- package/src/client/hooks/ui/toasts.ts +12 -1
- package/src/shared/lib/ui/titlebar.test.ts +124 -0
- package/src/shared/lib/ui/titlebar.ts +54 -0
- package/src/shared/lib/workspace/mobile-dock.test.ts +65 -0
- package/src/shared/lib/workspace/mobile-dock.ts +55 -0
- package/src/test-support/silent-socket.ts +69 -0
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { useRef, useState } from 'preact/hooks';
|
|
2
2
|
import type { ToastData } from '@/client/components/common/Toast';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/** What `useToasts` hands back — named, so a component takes the contract rather than `ReturnType`. */
|
|
5
|
+
export interface ToastsHandle {
|
|
6
|
+
toasts: ToastData[];
|
|
7
|
+
pushToast: (
|
|
8
|
+
message: string,
|
|
9
|
+
type?: 'success' | 'error',
|
|
10
|
+
options?: { action?: ToastData['action']; duration?: number },
|
|
11
|
+
) => void;
|
|
12
|
+
dismissToast: (id: number) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useToasts(): ToastsHandle {
|
|
5
16
|
const [toasts, setToasts] = useState<ToastData[]>([]);
|
|
6
17
|
const toastIdRef = useRef(0);
|
|
7
18
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The PWA titlebar insets, evaluated against the two platform geometries.
|
|
8
|
+
*
|
|
9
|
+
* The bug this pins is arithmetic, so asserting the STRING would pass while the
|
|
10
|
+
* wrong edge was reserved. The expressions are therefore substituted with real
|
|
11
|
+
* env values and evaluated:
|
|
12
|
+
*
|
|
13
|
+
* - macOS puts the window controls on the LEFT: `titlebar-area-x` is ~76px and
|
|
14
|
+
* `titlebar-area-width` is the rest of the window. The right inset must be
|
|
15
|
+
* the BASE padding — the old form `100vw - width` reserved 76px of dead
|
|
16
|
+
* space between the last navbar control and the window's right edge.
|
|
17
|
+
* - Windows/Linux put them on the RIGHT: `x` is 0, so both forms agree and the
|
|
18
|
+
* right inset is the overlay's own width.
|
|
19
|
+
* - A browser tab has neither: both env variables fall back, and the base
|
|
20
|
+
* padding is all that is left.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, expect, test } from 'bun:test';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
TITLEBAR_OVERLAY_RIGHT,
|
|
27
|
+
titlebarPaddingLeft,
|
|
28
|
+
titlebarPaddingRight,
|
|
29
|
+
} from '@/shared/lib/ui/titlebar';
|
|
30
|
+
|
|
31
|
+
const REM = 16;
|
|
32
|
+
const VIEWPORT = 1440;
|
|
33
|
+
|
|
34
|
+
interface Geometry {
|
|
35
|
+
/** `titlebar-area-x`; absent means the browser tab's fallback. */
|
|
36
|
+
x?: number;
|
|
37
|
+
/** `titlebar-area-width`; absent means the fallback. */
|
|
38
|
+
width?: number;
|
|
39
|
+
safeLeft?: number;
|
|
40
|
+
safeRight?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Evaluate one of the returned declarations.
|
|
45
|
+
*
|
|
46
|
+
* `env(name, fallback)` resolves to the geometry's value or its fallback, `vw`
|
|
47
|
+
* resolves to the viewport, and `max()`/`calc()` become their JS equivalents —
|
|
48
|
+
* which is exactly what a browser computes, minus unit conversion.
|
|
49
|
+
*/
|
|
50
|
+
function evaluate(expression: string, geometry: Geometry): number {
|
|
51
|
+
const values: Record<string, number | undefined> = {
|
|
52
|
+
'titlebar-area-x': geometry.x,
|
|
53
|
+
'titlebar-area-width': geometry.width,
|
|
54
|
+
'safe-area-inset-left': geometry.safeLeft,
|
|
55
|
+
'safe-area-inset-right': geometry.safeRight,
|
|
56
|
+
};
|
|
57
|
+
const substituted = expression
|
|
58
|
+
// `vw` first: the fallback inside an `env()` can itself be a viewport unit
|
|
59
|
+
// (`env(titlebar-area-width, 100vw)`), and resolving it afterwards would
|
|
60
|
+
// hand `parseLength` a unit it cannot read.
|
|
61
|
+
.replace(/([\d.]+)vw/g, (_match, value: string) => `${(Number(value) / 100) * VIEWPORT}px`)
|
|
62
|
+
.replace(
|
|
63
|
+
/env\(\s*([a-z-]+)\s*,\s*([^)]+)\)/g,
|
|
64
|
+
(_match, name: string, fallback: string) => `${values[name] ?? parseLength(fallback)}px`,
|
|
65
|
+
)
|
|
66
|
+
.replace(/max\(/g, 'Math.max(')
|
|
67
|
+
.replace(/calc\(/g, '(');
|
|
68
|
+
const js = substituted.replace(/([\d.]+)(px|rem)/g, (_match, value: string, unit: string) =>
|
|
69
|
+
unit === 'rem' ? String(Number(value) * REM) : value,
|
|
70
|
+
);
|
|
71
|
+
return Function(`return (${js})`)() as number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseLength(value: string): number {
|
|
75
|
+
const match = /^([\d.]+)(px|rem)?$/.exec(value.trim());
|
|
76
|
+
if (!match) throw new Error(`Unsupported length: ${value}`);
|
|
77
|
+
return match[2] === 'rem' ? Number(match[1]) * REM : Number(match[1]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** macOS: traffic lights on the left, ~76px of them. */
|
|
81
|
+
const MACOS: Geometry = { x: 76, width: VIEWPORT - 76 };
|
|
82
|
+
/** Windows: controls and the app menu on the right, ~138px of them. */
|
|
83
|
+
const WINDOWS: Geometry = { x: 0, width: VIEWPORT - 138 };
|
|
84
|
+
/** A browser tab: no WCO at all, so both variables take their fallbacks. */
|
|
85
|
+
const TAB: Geometry = {};
|
|
86
|
+
|
|
87
|
+
describe('titlebar insets', () => {
|
|
88
|
+
test('the right inset is the space AFTER the title bar area, not before it', () => {
|
|
89
|
+
// The overlay sits between the area's far edge and the window's right edge.
|
|
90
|
+
expect(TITLEBAR_OVERLAY_RIGHT).toContain('env(titlebar-area-x');
|
|
91
|
+
expect(TITLEBAR_OVERLAY_RIGHT).toContain('env(titlebar-area-width');
|
|
92
|
+
|
|
93
|
+
// macOS: the traffic lights are on the LEFT, so nothing may be reserved on
|
|
94
|
+
// the right. This is the reported gap.
|
|
95
|
+
expect(evaluate(titlebarPaddingRight('1rem'), MACOS)).toBe(REM);
|
|
96
|
+
// Windows: the overlay is on the right, and its width is what clears it.
|
|
97
|
+
expect(evaluate(titlebarPaddingRight('1rem'), WINDOWS)).toBe(138);
|
|
98
|
+
// No WCO: the base padding, unchanged.
|
|
99
|
+
expect(evaluate(titlebarPaddingRight('1rem'), TAB)).toBe(REM);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('the left inset is the title bar area origin', () => {
|
|
103
|
+
expect(evaluate(titlebarPaddingLeft('1rem'), MACOS)).toBe(76);
|
|
104
|
+
expect(evaluate(titlebarPaddingLeft('1rem'), WINDOWS)).toBe(REM);
|
|
105
|
+
expect(evaluate(titlebarPaddingLeft('1rem'), TAB)).toBe(REM);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the phone variants keep the notch insets', () => {
|
|
109
|
+
expect(evaluate(titlebarPaddingLeft('0.75rem', true), { ...MACOS, safeLeft: 44 })).toBe(76);
|
|
110
|
+
expect(evaluate(titlebarPaddingLeft('0.75rem', true), { x: 0, safeLeft: 44 })).toBe(44);
|
|
111
|
+
// A landscape phone's right notch clears the edge on its own, and the
|
|
112
|
+
// overlay's width is still honoured when it is the larger of the two.
|
|
113
|
+
expect(evaluate(titlebarPaddingRight('0.75rem', true), { x: 0, width: VIEWPORT - 138, safeRight: 44 })).toBe(138);
|
|
114
|
+
expect(evaluate(titlebarPaddingRight('0.75rem', true), { ...MACOS, safeRight: 44 })).toBe(44);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('the old form is the bug, stated as a control', () => {
|
|
118
|
+
// `100vw - titlebar-area-width` on macOS: the traffic lights' 76px charged
|
|
119
|
+
// to the wrong edge, which is the strip the report showed.
|
|
120
|
+
const buggy = `max(1rem, calc(100vw - env(titlebar-area-width, 100vw)))`;
|
|
121
|
+
expect(evaluate(buggy, MACOS)).toBe(76);
|
|
122
|
+
expect(evaluate(buggy, WINDOWS)).toBe(138);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The insets that keep a header's controls clear of the PWA window controls
|
|
8
|
+
* (Window Controls Overlay), stated ONCE because four headers draw them.
|
|
9
|
+
*
|
|
10
|
+
* The `titlebar-area-*` env variables describe the area the page may use: it
|
|
11
|
+
* runs from the frame edge to the innermost edge of the overlay that houses the
|
|
12
|
+
* window controls. Which SIDE that overlay is on is the platform's choice, and
|
|
13
|
+
* `titlebar-area-x` is the answer:
|
|
14
|
+
*
|
|
15
|
+
* - macOS puts the traffic lights on the LEFT, so `x` is ~76px and
|
|
16
|
+
* `titlebar-area-width` is `viewport - 76`;
|
|
17
|
+
* - Windows and Linux put the controls (and Chrome's `⋮` app menu) on the
|
|
18
|
+
* RIGHT, so `x` is `0` and `titlebar-area-width` is `viewport - overlay`.
|
|
19
|
+
*
|
|
20
|
+
* So the LEFT inset is `x`, and the RIGHT inset is whatever is left over after
|
|
21
|
+
* the area ENDS — `viewport - (x + width)` — never `viewport - width`. The
|
|
22
|
+
* second form is what the headers used to compute, and on macOS it reserved the
|
|
23
|
+
* traffic lights' width on the wrong edge: a 76px dead strip between the last
|
|
24
|
+
* navbar control and the window's right edge, with no control anywhere near it.
|
|
25
|
+
* On Windows the two forms agree, which is why the bug only showed on one
|
|
26
|
+
* platform.
|
|
27
|
+
*
|
|
28
|
+
* `safeArea` adds the iOS/Android notch insets, which the phone headers carry.
|
|
29
|
+
* A desktop PWA has them at 0, so one helper serves both.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The width of the overlay that sits to the RIGHT of the title bar area.
|
|
34
|
+
*
|
|
35
|
+
* Both env variables are read with a fallback, so this is `0px` in a browser
|
|
36
|
+
* tab (where WCO is not active) and on macOS, and the platform's real control
|
|
37
|
+
* width on Windows/Linux.
|
|
38
|
+
*/
|
|
39
|
+
export const TITLEBAR_OVERLAY_RIGHT =
|
|
40
|
+
'calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw))';
|
|
41
|
+
|
|
42
|
+
/** `base`-sized padding that also clears the window controls on the left. */
|
|
43
|
+
export function titlebarPaddingLeft(base: string, safeArea = false): string {
|
|
44
|
+
return safeArea
|
|
45
|
+
? `max(${base}, env(safe-area-inset-left, 0px), env(titlebar-area-x, 0px))`
|
|
46
|
+
: `max(${base}, env(titlebar-area-x, 0px))`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `base`-sized padding that also clears the window controls on the right. */
|
|
50
|
+
export function titlebarPaddingRight(base: string, safeArea = false): string {
|
|
51
|
+
return safeArea
|
|
52
|
+
? `max(${base}, env(safe-area-inset-right, 0px), ${TITLEBAR_OVERLAY_RIGHT})`
|
|
53
|
+
: `max(${base}, ${TITLEBAR_OVERLAY_RIGHT})`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which panels each phone drawer offers.
|
|
8
|
+
*
|
|
9
|
+
* Both rules are silent failures when wrong, which is why they are pinned:
|
|
10
|
+
* a panel on BOTH strips is one panel with two chips (and one of them is a
|
|
11
|
+
* second place to be), while a panel on NEITHER is a placement that looks like
|
|
12
|
+
* an uninstall.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { phoneLeftStripVisible, phoneRightPanels } from '@/shared/lib/workspace/mobile-dock';
|
|
17
|
+
import type { PanelSide } from '@/shared/lib/workspace/left-dock';
|
|
18
|
+
|
|
19
|
+
const SESSIONS = 'sessions';
|
|
20
|
+
|
|
21
|
+
function panels(...rows: [string, PanelSide][]) {
|
|
22
|
+
return rows.map(([id, side]) => ({ id, side }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('phoneRightPanels', () => {
|
|
26
|
+
test('drops the session list, which the phone draws as its own screen', () => {
|
|
27
|
+
const all = panels([SESSIONS, 'left'], ['files', 'right']);
|
|
28
|
+
expect(phoneRightPanels(all, SESSIONS, false).map((p) => p.id)).toEqual(['files']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('with the rail OFF a left-placed panel stays here — the left drawer cannot reach it', () => {
|
|
32
|
+
const all = panels([SESSIONS, 'left'], ['files', 'right'], ['plugin:demo:rightPanel', 'left']);
|
|
33
|
+
expect(phoneRightPanels(all, SESSIONS, false).map((p) => p.id)).toEqual(['files', 'plugin:demo:rightPanel']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('with the rail ON the left drawer owns it, so this one must not draw it twice', () => {
|
|
37
|
+
const all = panels([SESSIONS, 'left'], ['files', 'right'], ['plugin:demo:rightPanel', 'left']);
|
|
38
|
+
expect(phoneRightPanels(all, SESSIONS, true).map((p) => p.id)).toEqual(['files']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('the rail being on never hides a RIGHT-column view', () => {
|
|
42
|
+
const all = panels(['git', 'right'], ['todo', 'right']);
|
|
43
|
+
expect(phoneRightPanels(all, SESSIONS, true).map((p) => p.id)).toEqual(['git', 'todo']);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('phoneLeftStripVisible', () => {
|
|
48
|
+
test('no rail, no strip', () => {
|
|
49
|
+
expect(phoneLeftStripVisible(false, 2, true)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a strip holding only Sessions is chrome that never changes', () => {
|
|
53
|
+
expect(phoneLeftStripVisible(true, 0, true)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a placed panel is a second destination, so the strip appears', () => {
|
|
57
|
+
expect(phoneLeftStripVisible(true, 1, true)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a stored view that no longer resolves still gets the strip — its body is a notice', () => {
|
|
61
|
+
// Without this the drawer would be stuck on a notice with no way back to
|
|
62
|
+
// the session list: the body has no chips of its own.
|
|
63
|
+
expect(phoneLeftStripVisible(true, 0, false)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which panels each phone drawer offers.
|
|
8
|
+
*
|
|
9
|
+
* The phone has TWO drawers and one catalog, so the split is a rule rather
|
|
10
|
+
* than a per-component filter. It used to be one rule for the right drawer
|
|
11
|
+
* (`all`, minus the session list), because a panel placed on the left had
|
|
12
|
+
* nowhere else to be reached — the left drawer was a plain session list. Now
|
|
13
|
+
* the left drawer draws the dock too, so leaving `all` in the right one puts a
|
|
14
|
+
* single panel on two strips of one phone.
|
|
15
|
+
*
|
|
16
|
+
* Pure, and separate from both components, so the two drawers cannot drift and
|
|
17
|
+
* the rule can be tested without mounting either.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { PanelSide } from '@/shared/lib/workspace/left-dock';
|
|
21
|
+
|
|
22
|
+
/** The parts of a catalog entry these rules read. */
|
|
23
|
+
interface SidedPanel {
|
|
24
|
+
id: string;
|
|
25
|
+
side: PanelSide;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The right drawer's chips.
|
|
30
|
+
*
|
|
31
|
+
* `all` while the left rail is OFF: a placed panel is then unreachable from the
|
|
32
|
+
* left drawer (it has no strip), and hiding it from both would make the
|
|
33
|
+
* placement look like an uninstall. With the rail on the left drawer owns them.
|
|
34
|
+
*/
|
|
35
|
+
export function phoneRightPanels<T extends SidedPanel>(
|
|
36
|
+
panels: readonly T[],
|
|
37
|
+
sessionsViewId: string,
|
|
38
|
+
dockEnabled: boolean,
|
|
39
|
+
): T[] {
|
|
40
|
+
return panels.filter(
|
|
41
|
+
(panel) => panel.id !== sessionsViewId && !(dockEnabled && panel.side === 'left'),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether the left drawer draws its chip strip.
|
|
47
|
+
*
|
|
48
|
+
* A strip holding one chip is chrome that never changes, so it needs a second
|
|
49
|
+
* destination — either a placed panel, or a stored view that no longer
|
|
50
|
+
* resolves. The second half is the escape hatch: that body is a notice, and
|
|
51
|
+
* without the strip the session list would be unreachable.
|
|
52
|
+
*/
|
|
53
|
+
export function phoneLeftStripVisible(dockEnabled: boolean, placedCount: number, showingSessions: boolean): boolean {
|
|
54
|
+
return dockEnabled && (placedCount > 0 || !showingSessions);
|
|
55
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A `WebSocket` that never dials, and the teardown pair that goes with it.
|
|
8
|
+
*
|
|
9
|
+
* A suite that mounts a component reading a realtime topic subscribes to the
|
|
10
|
+
* unified channel on mount, and `dial()` then constructs a real socket from
|
|
11
|
+
* `window.location`. Two failure shapes follow, and both are reported against
|
|
12
|
+
* whichever file runs NEXT rather than the one that caused them:
|
|
13
|
+
*
|
|
14
|
+
* - a happy-dom window created with no URL reports `about:blank`, whose origin
|
|
15
|
+
* is `null`, so `realtimeSocketUrl()` produces `ws:///api/realtime/ws`. Bun
|
|
16
|
+
* constructs that object without complaint and fails it ASYNCHRONOUSLY, after
|
|
17
|
+
* the suite has already restored the DOM globals — measured as
|
|
18
|
+
* `Unhandled error. (ErrorEvent { isTrusted: [Getter] })` between tests, and
|
|
19
|
+
* then as `Cannot call beforeAll() after the test run has completed` in the
|
|
20
|
+
* next file, because the error tears the run down mid-file.
|
|
21
|
+
* - a window WITH an origin dials a port nothing listens on, which fails the
|
|
22
|
+
* same way (a refused connection is also asynchronous).
|
|
23
|
+
*
|
|
24
|
+
* `resetRealtimeClient()` in `afterEach` is not enough on its own: it closes a
|
|
25
|
+
* socket that already exists, and the dial's own failure has not been delivered
|
|
26
|
+
* yet. Silencing the CONSTRUCTOR is what removes the failure at its source.
|
|
27
|
+
*
|
|
28
|
+
* The subject of such a suite is its own markup, never the channel, so this
|
|
29
|
+
* stub sends nothing and reports nothing. A suite that really drives the
|
|
30
|
+
* channel uses `startRealtimeTestServer` instead, which injects the runner's
|
|
31
|
+
* own `WebSocket` (see `pristineWebSocket`).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { realtimeClient, resetRealtimeClient } from '@/shared/lib/realtime/client';
|
|
35
|
+
|
|
36
|
+
/** `WebSocket.CLOSING` / `CLOSED`, spelled out: the client reads them off the
|
|
37
|
+
* instance, and this object need not carry the statics. */
|
|
38
|
+
class SilentSocket {
|
|
39
|
+
static readonly CONNECTING = 0;
|
|
40
|
+
static readonly OPEN = 1;
|
|
41
|
+
static readonly CLOSING = 2;
|
|
42
|
+
static readonly CLOSED = 3;
|
|
43
|
+
readyState = SilentSocket.CLOSED;
|
|
44
|
+
onopen: (() => void) | null = null;
|
|
45
|
+
onclose: (() => void) | null = null;
|
|
46
|
+
onmessage: ((event: { data: string }) => void) | null = null;
|
|
47
|
+
onerror: (() => void) | null = null;
|
|
48
|
+
send(): void {}
|
|
49
|
+
close(): void {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Point the channel at `SilentSocket`. Call it BEFORE mounting anything that
|
|
54
|
+
* subscribes — a dial that already happened is not retracted by this.
|
|
55
|
+
*/
|
|
56
|
+
export function silenceRealtime(): void {
|
|
57
|
+
realtimeClient.setSocketConstructor(SilentSocket as unknown as typeof WebSocket);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Undo `silenceRealtime` and drop the channel's state. This is the `afterEach`
|
|
62
|
+
* half: the constructor goes back to `null` (the real global) so the next file
|
|
63
|
+
* is not left silenced, and the reset clears the topic cache the stubbed
|
|
64
|
+
* channel accumulated.
|
|
65
|
+
*/
|
|
66
|
+
export function restoreRealtime(): void {
|
|
67
|
+
resetRealtimeClient();
|
|
68
|
+
realtimeClient.setSocketConstructor(null);
|
|
69
|
+
}
|