@casualoffice/sheets 0.9.0 → 0.10.0
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/api-BI2VLYQ6.d.cts +62 -0
- package/dist/api-BI2VLYQ6.d.ts +62 -0
- package/dist/chrome.cjs +2569 -0
- package/dist/chrome.cjs.map +1 -0
- package/dist/chrome.d.cts +96 -0
- package/dist/chrome.d.ts +96 -0
- package/dist/chrome.js +2556 -0
- package/dist/chrome.js.map +1 -0
- package/dist/collab.cjs +770 -0
- package/dist/collab.cjs.map +1 -0
- package/dist/collab.d.cts +248 -0
- package/dist/collab.d.ts +248 -0
- package/dist/collab.js +737 -0
- package/dist/collab.js.map +1 -0
- package/dist/embed/embed-runtime.js +128 -128
- package/dist/embed.cjs +166 -0
- package/dist/embed.cjs.map +1 -1
- package/dist/embed.d.cts +78 -3
- package/dist/embed.d.ts +78 -3
- package/dist/embed.js +166 -0
- package/dist/embed.js.map +1 -1
- package/dist/index.cjs +262 -165
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +271 -168
- package/dist/index.js.map +1 -1
- package/dist/{protocol--KyBQUjU.d.cts → protocol-Cq4Cdoyi.d.cts} +19 -2
- package/dist/{protocol-cEzy7S0i.d.ts → protocol-DqDaG2yG.d.ts} +19 -2
- package/dist/sheets.cjs +102 -16
- package/dist/sheets.cjs.map +1 -1
- package/dist/sheets.d.cts +49 -63
- package/dist/sheets.d.ts +49 -63
- package/dist/sheets.js +111 -19
- package/dist/sheets.js.map +1 -1
- package/package.json +28 -3
- package/src/chrome/AutoSumPicker.tsx +176 -0
- package/src/chrome/BordersPicker.tsx +171 -0
- package/src/chrome/ChromeBottom.tsx +18 -0
- package/src/chrome/ChromeTop.tsx +21 -0
- package/src/chrome/ColorPicker.tsx +220 -0
- package/src/chrome/FindReplace.tsx +370 -0
- package/src/chrome/FormulaBar.tsx +378 -0
- package/src/chrome/Icon.tsx +43 -0
- package/src/chrome/MenuBar.tsx +336 -0
- package/src/chrome/NameBox.tsx +347 -0
- package/src/chrome/SheetTabs.tsx +346 -0
- package/src/chrome/StatusBar.tsx +232 -0
- package/src/chrome/Toolbar.tsx +401 -0
- package/src/chrome/fonts.ts +42 -0
- package/src/chrome/index.ts +24 -0
- package/src/collab/attachCollab.ts +151 -0
- package/src/collab/bridge-helpers.ts +97 -0
- package/src/collab/bridge.ts +885 -0
- package/src/collab/bridge.unit.test.ts +160 -0
- package/src/collab/index.ts +38 -0
- package/src/collab/replay-retry.ts +137 -0
- package/src/collab/replay-retry.unit.test.ts +223 -0
- package/src/collab/ws-url.ts +20 -0
- package/src/collab/ws-url.unit.test.ts +35 -0
- package/src/embed/EmbedHostTransport.ts +16 -1
- package/src/embed/EmbedTransport.ts +16 -0
- package/src/embed/EmbedTransport.unit.test.ts +88 -2
- package/src/embed/index.ts +7 -0
- package/src/embed/protocol.ts +34 -0
- package/src/embed-runtime/index.tsx +20 -0
- package/src/sheets/CasualSheets.tsx +204 -33
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-function unit tests for the bridge helpers. Runs under
|
|
3
|
+
* `node --import tsx` via `node:test`; no extra test runner installed.
|
|
4
|
+
*
|
|
5
|
+
* Stateful bridge logic (Yjs observers, command-service hooks, compaction)
|
|
6
|
+
* is covered by tests/e2e/coedit-*.spec.ts. This file only covers the
|
|
7
|
+
* pure stuff that doesn't need a Univer instance.
|
|
8
|
+
*
|
|
9
|
+
* Run with: `pnpm test:unit`
|
|
10
|
+
*/
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
|
|
14
|
+
import { deepRewriteUnitId } from './bridge-helpers';
|
|
15
|
+
|
|
16
|
+
test('deepRewriteUnitId rewrites top-level unitId', () => {
|
|
17
|
+
const params = { unitId: 'sender-id', subUnitId: 'sheet-1', value: 'A' };
|
|
18
|
+
const out = deepRewriteUnitId(params, 'local-id') as typeof params;
|
|
19
|
+
assert.equal(out.unitId, 'local-id');
|
|
20
|
+
assert.equal(out.subUnitId, 'sheet-1');
|
|
21
|
+
assert.notEqual(out, params, 'should return a clone when changes happen');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('deepRewriteUnitId rewrites nested unitId (range.unitId)', () => {
|
|
25
|
+
const params = {
|
|
26
|
+
range: { unitId: 'sender-id', startRow: 0, startColumn: 0 },
|
|
27
|
+
rangeData: [{ unitId: 'sender-id', sr: 0 }],
|
|
28
|
+
};
|
|
29
|
+
const out = deepRewriteUnitId(params, 'local-id') as {
|
|
30
|
+
range: { unitId: string };
|
|
31
|
+
rangeData: Array<{ unitId: string }>;
|
|
32
|
+
};
|
|
33
|
+
assert.equal(out.range.unitId, 'local-id');
|
|
34
|
+
assert.equal(out.rangeData[0].unitId, 'local-id');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('deepRewriteUnitId rewrites unitId inside array entries', () => {
|
|
38
|
+
const params = {
|
|
39
|
+
moves: [
|
|
40
|
+
{ source: { unitId: 'sender-id' }, target: { unitId: 'sender-id' } },
|
|
41
|
+
{ source: { unitId: 'sender-id' } },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
const out = deepRewriteUnitId(params, 'local-id') as {
|
|
45
|
+
moves: Array<{ source: { unitId: string }; target?: { unitId: string } }>;
|
|
46
|
+
};
|
|
47
|
+
assert.equal(out.moves[0].source.unitId, 'local-id');
|
|
48
|
+
assert.equal(out.moves[0].target?.unitId, 'local-id');
|
|
49
|
+
assert.equal(out.moves[1].source.unitId, 'local-id');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('deepRewriteUnitId returns input unchanged when no unitId present', () => {
|
|
53
|
+
const params = { subUnitId: 'sheet-1', cellValue: { 0: { 0: { v: 1 } } } };
|
|
54
|
+
const out = deepRewriteUnitId(params, 'local-id');
|
|
55
|
+
assert.strictEqual(out, params, 'should return same reference when nothing changes');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('deepRewriteUnitId leaves unitId unchanged when already local', () => {
|
|
59
|
+
const params = { unitId: 'local-id', other: 1 };
|
|
60
|
+
const out = deepRewriteUnitId(params, 'local-id');
|
|
61
|
+
assert.strictEqual(out, params);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('deepRewriteUnitId does not descend into class instances', () => {
|
|
65
|
+
class Custom {
|
|
66
|
+
unitId = 'sender-id';
|
|
67
|
+
}
|
|
68
|
+
const inst = new Custom();
|
|
69
|
+
const params = { wrap: inst };
|
|
70
|
+
const out = deepRewriteUnitId(params, 'local-id') as { wrap: Custom };
|
|
71
|
+
// Class instances are not plain objects, so the walker stops at the
|
|
72
|
+
// boundary — the unitId stays as-is. Univer mutation params are
|
|
73
|
+
// required to be JSON-friendly plain objects; anything carrying a
|
|
74
|
+
// class instance is out of scope for the bridge.
|
|
75
|
+
assert.equal(out.wrap.unitId, 'sender-id');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('deepRewriteUnitId handles deeply nested unitId chains', () => {
|
|
79
|
+
const params = {
|
|
80
|
+
a: { b: { c: { d: { unitId: 'sender-id' } } } },
|
|
81
|
+
};
|
|
82
|
+
const out = deepRewriteUnitId(params, 'local-id') as {
|
|
83
|
+
a: { b: { c: { d: { unitId: string } } } };
|
|
84
|
+
};
|
|
85
|
+
assert.equal(out.a.b.c.d.unitId, 'local-id');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('deepRewriteUnitId preserves null and primitive leaves', () => {
|
|
89
|
+
const params = {
|
|
90
|
+
unitId: 'sender-id',
|
|
91
|
+
nullField: null,
|
|
92
|
+
boolField: true,
|
|
93
|
+
numField: 42,
|
|
94
|
+
strField: 'hello',
|
|
95
|
+
};
|
|
96
|
+
const out = deepRewriteUnitId(params, 'local-id') as Record<string, unknown>;
|
|
97
|
+
assert.equal(out.unitId, 'local-id');
|
|
98
|
+
assert.equal(out.nullField, null);
|
|
99
|
+
assert.equal(out.boolField, true);
|
|
100
|
+
assert.equal(out.numField, 42);
|
|
101
|
+
assert.equal(out.strField, 'hello');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/* ── rewriteJson1OpPathUnitId — Stream F1 drawing-sync fix ───────── */
|
|
105
|
+
|
|
106
|
+
import { rewriteJson1OpPathUnitId } from './bridge-helpers';
|
|
107
|
+
|
|
108
|
+
test('rewriteJson1OpPathUnitId swaps leading unitId in a single JSONOp', () => {
|
|
109
|
+
// Realistic shape from `sheet.mutation.set-drawing-apply`:
|
|
110
|
+
// path-prefix...trailing-component. Trailing component is an
|
|
111
|
+
// object describing the mutation (insert {i: ...}, remove {r: 0},
|
|
112
|
+
// edit {ed: ...}).
|
|
113
|
+
const op = ['owner-wb', 'sheet-1', 'data', 'drawing-7', { i: { drawingId: 'drawing-7' } }];
|
|
114
|
+
const out = rewriteJson1OpPathUnitId(op, 'owner-wb', 'joiner-wb') as unknown[];
|
|
115
|
+
assert.equal(out[0], 'joiner-wb');
|
|
116
|
+
assert.equal(out[1], 'sheet-1');
|
|
117
|
+
assert.equal(out[2], 'data');
|
|
118
|
+
assert.equal(out[3], 'drawing-7');
|
|
119
|
+
// Trailing component should be preserved by reference (we shallow-copy).
|
|
120
|
+
assert.deepEqual(out[4], { i: { drawingId: 'drawing-7' } });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('rewriteJson1OpPathUnitId walks a JSONOpList (op of ops)', () => {
|
|
124
|
+
const opList = [
|
|
125
|
+
['owner-wb', 'sheet-1', 'data', 'd-1', { i: { x: 1 } }],
|
|
126
|
+
['owner-wb', 'sheet-1', 'order', 0, { i: 'd-1' }],
|
|
127
|
+
];
|
|
128
|
+
const out = rewriteJson1OpPathUnitId(opList, 'owner-wb', 'joiner-wb') as unknown[][];
|
|
129
|
+
assert.equal(out.length, 2);
|
|
130
|
+
assert.equal(out[0][0], 'joiner-wb');
|
|
131
|
+
assert.equal(out[1][0], 'joiner-wb');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('rewriteJson1OpPathUnitId returns input unchanged when no match at [0]', () => {
|
|
135
|
+
// E.g. some other plugin's json1 op that doesn't lead with unitId.
|
|
136
|
+
const op = ['something-else', 'whatever', { i: 1 }];
|
|
137
|
+
const out = rewriteJson1OpPathUnitId(op, 'owner-wb', 'joiner-wb');
|
|
138
|
+
assert.equal(out, op); // identity — no copy made when nothing to do
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('rewriteJson1OpPathUnitId is a no-op when old === new', () => {
|
|
142
|
+
const op = ['same-wb', 'sheet-1', 'data', 'd-1', { i: { x: 1 } }];
|
|
143
|
+
const out = rewriteJson1OpPathUnitId(op, 'same-wb', 'same-wb');
|
|
144
|
+
assert.equal(out, op);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('rewriteJson1OpPathUnitId is a no-op for non-array input', () => {
|
|
148
|
+
assert.equal(rewriteJson1OpPathUnitId(null, 'a', 'b'), null);
|
|
149
|
+
assert.equal(rewriteJson1OpPathUnitId(undefined, 'a', 'b'), undefined);
|
|
150
|
+
assert.equal(rewriteJson1OpPathUnitId('string', 'a', 'b'), 'string');
|
|
151
|
+
assert.equal(rewriteJson1OpPathUnitId(42, 'a', 'b'), 42);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('rewriteJson1OpPathUnitId preserves a single-element op-list edge case', () => {
|
|
155
|
+
// JSONOpList with one entry — detection key is "first element is array".
|
|
156
|
+
const opList = [['owner-wb', 'sheet-1', 'data', 'd-1', { i: 1 }]];
|
|
157
|
+
const out = rewriteJson1OpPathUnitId(opList, 'owner-wb', 'joiner-wb') as unknown[][];
|
|
158
|
+
assert.equal(out.length, 1);
|
|
159
|
+
assert.equal(out[0][0], 'joiner-wb');
|
|
160
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@casualoffice/sheets/collab` — opt-in real-time co-editing.
|
|
3
|
+
*
|
|
4
|
+
* The editor is collab-unaware until a host calls `attachCollab(api, opts)`.
|
|
5
|
+
* Yjs + Hocuspocus is the realtime transport; authoritative persistence stays
|
|
6
|
+
* host-side (WOPI / backend) via the save/exit event contract.
|
|
7
|
+
*
|
|
8
|
+
* Requires the host to provide `yjs` and `@hocuspocus/provider` (peer deps) so
|
|
9
|
+
* there's a single Yjs instance in the graph — two copies break `Y.Doc`
|
|
10
|
+
* identity and awareness.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
attachCollab,
|
|
15
|
+
type AttachCollabOptions,
|
|
16
|
+
type CollabAttachable,
|
|
17
|
+
type CollabHandle,
|
|
18
|
+
type CollabRole,
|
|
19
|
+
type CollabConnectionStatus,
|
|
20
|
+
} from './attachCollab';
|
|
21
|
+
|
|
22
|
+
// The mutation bridge — the framework-agnostic core (subscribes to
|
|
23
|
+
// onMutationExecutedForCollab, replays with fromCollab, guards __splitChunk__).
|
|
24
|
+
// Exposed for hosts that drive their own provider/doc lifecycle instead of
|
|
25
|
+
// using attachCollab's batteries-included transport.
|
|
26
|
+
export {
|
|
27
|
+
startBridge,
|
|
28
|
+
type BridgeHandle,
|
|
29
|
+
type BridgeOptions,
|
|
30
|
+
SYNCED_MUTATIONS,
|
|
31
|
+
REVERTABLE_MUTATIONS,
|
|
32
|
+
} from './bridge';
|
|
33
|
+
export {
|
|
34
|
+
type ReplayFailureRecord,
|
|
35
|
+
type ReplayClassification,
|
|
36
|
+
classifyReplayError,
|
|
37
|
+
} from './replay-retry';
|
|
38
|
+
export { deepRewriteUnitId, rewriteJson1OpPathUnitId } from './bridge-helpers';
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay-failure retry classifier + scheduler.
|
|
3
|
+
*
|
|
4
|
+
* Pure helpers used by the Yjs bridge to recover from transient
|
|
5
|
+
* mutation-replay failures (most often dynamic-import chunk-load
|
|
6
|
+
* errors during the lazy-plugin gate) without conflating them with
|
|
7
|
+
* permanent failures (malformed mutation params, unknown command id).
|
|
8
|
+
*
|
|
9
|
+
* Decoupled from bridge.ts so we can unit-test the classification +
|
|
10
|
+
* backoff logic without standing up a full Yjs doc + Univer engine.
|
|
11
|
+
*
|
|
12
|
+
* See docs/PRODUCTION_PIPELINE.md → Stream A1 for design context.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Maximum retry attempts for transient failures. */
|
|
16
|
+
export const TRANSIENT_RETRY_DELAYS_MS = [300, 900, 2700] as const;
|
|
17
|
+
|
|
18
|
+
/** Maximum entries kept in the dead-letter ring buffer. */
|
|
19
|
+
export const DEAD_LETTER_CAP = 20;
|
|
20
|
+
|
|
21
|
+
export type ReplayClassification = 'transient' | 'permanent';
|
|
22
|
+
|
|
23
|
+
export interface ReplayFailureRecord {
|
|
24
|
+
/** Mutation id (e.g. 'sheet.mutation.set-range-values'). */
|
|
25
|
+
id: string;
|
|
26
|
+
/** Mutation params at time of failure (unfiltered; may be large). */
|
|
27
|
+
params: unknown;
|
|
28
|
+
/** Last error's message (string-coerced for log/UI safety). */
|
|
29
|
+
lastError: string;
|
|
30
|
+
/** Total attempts made (1 = first try, no retries). */
|
|
31
|
+
attempts: number;
|
|
32
|
+
/** ms-since-epoch of the FIRST failure for this record. */
|
|
33
|
+
firstFailedAt: number;
|
|
34
|
+
/** ms-since-epoch of the most recent failure for this record. */
|
|
35
|
+
lastFailedAt: number;
|
|
36
|
+
/** Whether the classifier called this transient or permanent. */
|
|
37
|
+
classification: ReplayClassification;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Decide whether a replay error is worth retrying.
|
|
42
|
+
*
|
|
43
|
+
* Transient class: dynamic-import / chunk-load failures. These come
|
|
44
|
+
* from the lazy-plugin gate fetching a webpack chunk over the
|
|
45
|
+
* network; a flap on the user's connection drops the fetch and the
|
|
46
|
+
* lazy `import()` rejects with one of several distinct shapes:
|
|
47
|
+
*
|
|
48
|
+
* - Webpack 5: `ChunkLoadError` with name === 'ChunkLoadError'
|
|
49
|
+
* - Webpack 4 / vite: 'Loading chunk N failed.'
|
|
50
|
+
* - Vite / native ESM: 'Failed to fetch dynamically imported module'
|
|
51
|
+
* - Native fetch under offline: 'NetworkError when attempting to fetch'
|
|
52
|
+
*
|
|
53
|
+
* All four resolve on retry once the network recovers. Everything
|
|
54
|
+
* else (executeCommand rejections from bad params, missing handlers,
|
|
55
|
+
* out-of-bounds ranges) is permanent — retrying just burns the same
|
|
56
|
+
* stack trace N more times.
|
|
57
|
+
*
|
|
58
|
+
* Conservative bias: when in doubt, classify permanent. A false
|
|
59
|
+
* negative (transient → permanent) costs us a dead-letter entry the
|
|
60
|
+
* user can re-trigger by reloading; a false positive (permanent →
|
|
61
|
+
* transient) wastes 4 s of retries on a known-broken mutation.
|
|
62
|
+
*/
|
|
63
|
+
export function classifyReplayError(err: unknown): ReplayClassification {
|
|
64
|
+
if (err == null) return 'permanent';
|
|
65
|
+
|
|
66
|
+
// Error instances: check name first (ChunkLoadError sets this even
|
|
67
|
+
// though it's not on stock Error), then message.
|
|
68
|
+
const e = err as { name?: unknown; message?: unknown };
|
|
69
|
+
const name = typeof e.name === 'string' ? e.name : '';
|
|
70
|
+
const message = typeof e.message === 'string' ? e.message : String(err);
|
|
71
|
+
|
|
72
|
+
if (name === 'ChunkLoadError') return 'transient';
|
|
73
|
+
|
|
74
|
+
const lower = message.toLowerCase();
|
|
75
|
+
if (lower.includes('loading chunk') && lower.includes('failed')) {
|
|
76
|
+
return 'transient';
|
|
77
|
+
}
|
|
78
|
+
if (lower.includes('failed to fetch dynamically imported')) {
|
|
79
|
+
return 'transient';
|
|
80
|
+
}
|
|
81
|
+
if (lower.includes('networkerror when attempting to fetch')) {
|
|
82
|
+
return 'transient';
|
|
83
|
+
}
|
|
84
|
+
// Generic 'network request failed' from some bundler error wrappers.
|
|
85
|
+
if (lower.includes('network request failed')) return 'transient';
|
|
86
|
+
|
|
87
|
+
return 'permanent';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Run `task` with up to `delays.length` retries. The first attempt is
|
|
92
|
+
* immediate; each subsequent attempt waits `delays[i-1]` ms.
|
|
93
|
+
*
|
|
94
|
+
* Resolves with the task's value on the first success. Rejects with
|
|
95
|
+
* the LAST error after all attempts fail.
|
|
96
|
+
*
|
|
97
|
+
* `shouldRetry` is called between attempts. If it returns false, the
|
|
98
|
+
* loop bails immediately with the current error — this lets the
|
|
99
|
+
* caller switch to permanent-failure handling mid-sequence (e.g. if
|
|
100
|
+
* a follow-up error reveals the real problem was permanent all along).
|
|
101
|
+
*/
|
|
102
|
+
export async function withRetry<T>(
|
|
103
|
+
task: () => Promise<T>,
|
|
104
|
+
delays: readonly number[],
|
|
105
|
+
shouldRetry: (err: unknown, attemptsSoFar: number) => boolean = () => true,
|
|
106
|
+
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
107
|
+
): Promise<T> {
|
|
108
|
+
let lastErr: unknown;
|
|
109
|
+
for (let i = 0; i <= delays.length; i += 1) {
|
|
110
|
+
try {
|
|
111
|
+
return await task();
|
|
112
|
+
} catch (err) {
|
|
113
|
+
lastErr = err;
|
|
114
|
+
const attempts = i + 1;
|
|
115
|
+
if (i >= delays.length) break;
|
|
116
|
+
if (!shouldRetry(err, attempts)) break;
|
|
117
|
+
await sleep(delays[i]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw lastErr;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Ring buffer for dead-letter records. Append-only; oldest entry
|
|
125
|
+
* evicts on overflow. Caller owns the backing array — we return a
|
|
126
|
+
* new array on push so React state updates can see the change by
|
|
127
|
+
* reference.
|
|
128
|
+
*/
|
|
129
|
+
export function pushDeadLetter(
|
|
130
|
+
buffer: readonly ReplayFailureRecord[],
|
|
131
|
+
rec: ReplayFailureRecord,
|
|
132
|
+
cap: number = DEAD_LETTER_CAP,
|
|
133
|
+
): ReplayFailureRecord[] {
|
|
134
|
+
const next =
|
|
135
|
+
buffer.length >= cap ? [...buffer.slice(buffer.length - cap + 1), rec] : [...buffer, rec];
|
|
136
|
+
return next;
|
|
137
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-state tests for the replay-retry classifier + scheduler. The
|
|
3
|
+
* bridge owns the integration (which depends on a live Yjs doc +
|
|
4
|
+
* Univer engine); these tests pin the failure-class boundary so the
|
|
5
|
+
* "what counts as transient" decision is checked-in instead of buried
|
|
6
|
+
* in production code.
|
|
7
|
+
*
|
|
8
|
+
* Run with: `pnpm test:unit`
|
|
9
|
+
*/
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
classifyReplayError,
|
|
15
|
+
DEAD_LETTER_CAP,
|
|
16
|
+
pushDeadLetter,
|
|
17
|
+
TRANSIENT_RETRY_DELAYS_MS,
|
|
18
|
+
withRetry,
|
|
19
|
+
type ReplayFailureRecord,
|
|
20
|
+
} from './replay-retry';
|
|
21
|
+
|
|
22
|
+
/* ── classifyReplayError ──────────────────────────────────────────── */
|
|
23
|
+
|
|
24
|
+
test('ChunkLoadError by name is transient', () => {
|
|
25
|
+
const err = Object.assign(new Error('Loading chunk 7 failed.'), {
|
|
26
|
+
name: 'ChunkLoadError',
|
|
27
|
+
});
|
|
28
|
+
assert.equal(classifyReplayError(err), 'transient');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('webpack "Loading chunk N failed" message is transient', () => {
|
|
32
|
+
// Bare Error with no special name — only the message tells us.
|
|
33
|
+
assert.equal(classifyReplayError(new Error('Loading chunk 42 failed.')), 'transient');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('vite "failed to fetch dynamically imported module" is transient', () => {
|
|
37
|
+
assert.equal(
|
|
38
|
+
classifyReplayError(new Error('Failed to fetch dynamically imported module: /assets/cf.js')),
|
|
39
|
+
'transient',
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('NetworkError when attempting to fetch is transient', () => {
|
|
44
|
+
assert.equal(
|
|
45
|
+
classifyReplayError(new Error('NetworkError when attempting to fetch resource.')),
|
|
46
|
+
'transient',
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('generic "network request failed" is transient', () => {
|
|
51
|
+
assert.equal(classifyReplayError(new Error('Network request failed')), 'transient');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('malformed-params TypeError is permanent', () => {
|
|
55
|
+
// Realistic shape: Univer's set-range-values throws TypeError when
|
|
56
|
+
// params.value is undefined.
|
|
57
|
+
assert.equal(
|
|
58
|
+
classifyReplayError(new TypeError("Cannot read properties of undefined (reading 'v')")),
|
|
59
|
+
'permanent',
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('unknown command id Error is permanent', () => {
|
|
64
|
+
assert.equal(
|
|
65
|
+
classifyReplayError(new Error('No command handler registered for "made.up.mutation"')),
|
|
66
|
+
'permanent',
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('null / undefined / number errors classify permanent (defensive)', () => {
|
|
71
|
+
assert.equal(classifyReplayError(null), 'permanent');
|
|
72
|
+
assert.equal(classifyReplayError(undefined), 'permanent');
|
|
73
|
+
assert.equal(classifyReplayError(42), 'permanent');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('case-insensitive: "LOADING CHUNK 1 FAILED" is transient', () => {
|
|
77
|
+
// The classifier lowercases before matching so we don't miss a
|
|
78
|
+
// bundler that screams its errors. Realistic case for some
|
|
79
|
+
// production-mode bundle wrappers.
|
|
80
|
+
assert.equal(classifyReplayError(new Error('LOADING CHUNK 1 FAILED.')), 'transient');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/* ── withRetry ──────────────────────────────────────────────────── */
|
|
84
|
+
|
|
85
|
+
test('withRetry resolves on first try when task succeeds', async () => {
|
|
86
|
+
let calls = 0;
|
|
87
|
+
const value = await withRetry(
|
|
88
|
+
() => {
|
|
89
|
+
calls += 1;
|
|
90
|
+
return Promise.resolve('ok');
|
|
91
|
+
},
|
|
92
|
+
[10, 20],
|
|
93
|
+
() => true,
|
|
94
|
+
() => Promise.resolve(),
|
|
95
|
+
);
|
|
96
|
+
assert.equal(value, 'ok');
|
|
97
|
+
assert.equal(calls, 1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('withRetry retries until a later attempt succeeds', async () => {
|
|
101
|
+
let calls = 0;
|
|
102
|
+
const value = await withRetry(
|
|
103
|
+
() => {
|
|
104
|
+
calls += 1;
|
|
105
|
+
if (calls < 3) return Promise.reject(new Error('chunk load failed'));
|
|
106
|
+
return Promise.resolve('eventually');
|
|
107
|
+
},
|
|
108
|
+
[10, 20, 30],
|
|
109
|
+
() => true,
|
|
110
|
+
() => Promise.resolve(),
|
|
111
|
+
);
|
|
112
|
+
assert.equal(value, 'eventually');
|
|
113
|
+
assert.equal(calls, 3);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('withRetry exhausts all attempts then rejects with last error', async () => {
|
|
117
|
+
let calls = 0;
|
|
118
|
+
await assert.rejects(
|
|
119
|
+
withRetry(
|
|
120
|
+
() => {
|
|
121
|
+
calls += 1;
|
|
122
|
+
return Promise.reject(new Error(`attempt ${calls}`));
|
|
123
|
+
},
|
|
124
|
+
[10, 20, 30],
|
|
125
|
+
() => true,
|
|
126
|
+
() => Promise.resolve(),
|
|
127
|
+
),
|
|
128
|
+
{ message: 'attempt 4' }, // 1 initial + 3 retries = 4 calls
|
|
129
|
+
);
|
|
130
|
+
assert.equal(calls, 4);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('withRetry bails early when shouldRetry returns false', async () => {
|
|
134
|
+
let calls = 0;
|
|
135
|
+
await assert.rejects(
|
|
136
|
+
withRetry(
|
|
137
|
+
() => {
|
|
138
|
+
calls += 1;
|
|
139
|
+
return Promise.reject(new Error('permanent'));
|
|
140
|
+
},
|
|
141
|
+
[10, 20, 30],
|
|
142
|
+
() => false, // never retry
|
|
143
|
+
() => Promise.resolve(),
|
|
144
|
+
),
|
|
145
|
+
{ message: 'permanent' },
|
|
146
|
+
);
|
|
147
|
+
assert.equal(calls, 1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('withRetry actually awaits the sleep delays (in order)', async () => {
|
|
151
|
+
const slept: number[] = [];
|
|
152
|
+
let calls = 0;
|
|
153
|
+
await assert.rejects(
|
|
154
|
+
withRetry(
|
|
155
|
+
() => {
|
|
156
|
+
calls += 1;
|
|
157
|
+
return Promise.reject(new Error('fail'));
|
|
158
|
+
},
|
|
159
|
+
[50, 150, 450],
|
|
160
|
+
() => true,
|
|
161
|
+
(ms) => {
|
|
162
|
+
slept.push(ms);
|
|
163
|
+
return Promise.resolve();
|
|
164
|
+
},
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
assert.deepEqual(slept, [50, 150, 450]);
|
|
168
|
+
assert.equal(calls, 4);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/* ── pushDeadLetter ─────────────────────────────────────────────── */
|
|
172
|
+
|
|
173
|
+
function makeRec(id: string): ReplayFailureRecord {
|
|
174
|
+
return {
|
|
175
|
+
id,
|
|
176
|
+
params: { foo: 1 },
|
|
177
|
+
lastError: 'boom',
|
|
178
|
+
attempts: 1,
|
|
179
|
+
firstFailedAt: 0,
|
|
180
|
+
lastFailedAt: 0,
|
|
181
|
+
classification: 'permanent',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
test('pushDeadLetter appends below cap', () => {
|
|
186
|
+
const buf = pushDeadLetter([], makeRec('a'));
|
|
187
|
+
assert.equal(buf.length, 1);
|
|
188
|
+
assert.equal(buf[0].id, 'a');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('pushDeadLetter evicts oldest when at cap', () => {
|
|
192
|
+
const small = 3;
|
|
193
|
+
let buf: readonly ReplayFailureRecord[] = [];
|
|
194
|
+
for (const id of ['a', 'b', 'c', 'd', 'e']) {
|
|
195
|
+
buf = pushDeadLetter(buf, makeRec(id), small);
|
|
196
|
+
}
|
|
197
|
+
assert.equal(buf.length, 3);
|
|
198
|
+
assert.deepEqual(
|
|
199
|
+
buf.map((r) => r.id),
|
|
200
|
+
['c', 'd', 'e'],
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('pushDeadLetter returns a new array (reference change)', () => {
|
|
205
|
+
// The CollabIndicator subscribes by reference — we MUST return a
|
|
206
|
+
// fresh array, not mutate in place.
|
|
207
|
+
const original: readonly ReplayFailureRecord[] = [makeRec('a')];
|
|
208
|
+
const next = pushDeadLetter(original, makeRec('b'));
|
|
209
|
+
assert.notEqual(next, original);
|
|
210
|
+
assert.equal(original.length, 1);
|
|
211
|
+
assert.equal(next.length, 2);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('TRANSIENT_RETRY_DELAYS_MS is the documented schedule', () => {
|
|
215
|
+
// Pin the schedule. If this changes, audit the indicator copy +
|
|
216
|
+
// CO-EDITING.md so the user-visible "should recover within ~4 s"
|
|
217
|
+
// claim stays accurate.
|
|
218
|
+
assert.deepEqual([...TRANSIENT_RETRY_DELAYS_MS], [300, 900, 2700]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('DEAD_LETTER_CAP is 20', () => {
|
|
222
|
+
assert.equal(DEAD_LETTER_CAP, 20);
|
|
223
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure WS-URL composition for attachCollab. Kept in its own module — free of
|
|
3
|
+
* any `@univerjs` / `@hocuspocus` imports — so it's unit-testable under
|
|
4
|
+
* `node:test` without dragging in the Univer ESM graph.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Build the room WS URL: `<server>?room=<id>[&p=<pw>]&role=<role>`. */
|
|
8
|
+
export function buildWsUrl(
|
|
9
|
+
server: string,
|
|
10
|
+
room: string,
|
|
11
|
+
role: 'view' | 'write',
|
|
12
|
+
password?: string,
|
|
13
|
+
): string {
|
|
14
|
+
const sep = server.includes('?') ? '&' : '?';
|
|
15
|
+
return (
|
|
16
|
+
`${server}${sep}room=${encodeURIComponent(room)}` +
|
|
17
|
+
`${password ? `&p=${encodeURIComponent(password)}` : ''}` +
|
|
18
|
+
`&role=${encodeURIComponent(role)}`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit coverage for attachCollab's URL composition — the one bit of our own
|
|
3
|
+
* logic that doesn't require a live WebSocket. The provider/bridge wiring is
|
|
4
|
+
* exercised end-to-end by the reference host's `tests/e2e/coedit-*.spec.ts`
|
|
5
|
+
* (which now drives the moved bridge through `@casualoffice/sheets/collab`).
|
|
6
|
+
*/
|
|
7
|
+
import { strict as assert } from 'node:assert';
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
|
|
10
|
+
import { buildWsUrl } from './ws-url';
|
|
11
|
+
|
|
12
|
+
test('buildWsUrl appends room + role with a leading ? on a bare server', () => {
|
|
13
|
+
assert.equal(buildWsUrl('wss://h/yjs', 'room1', 'write'), 'wss://h/yjs?room=room1&role=write');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('buildWsUrl uses & when the server URL already has a query', () => {
|
|
17
|
+
assert.equal(
|
|
18
|
+
buildWsUrl('wss://h/yjs?token=x', 'room1', 'view'),
|
|
19
|
+
'wss://h/yjs?token=x&room=room1&role=view',
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('buildWsUrl includes the password only when provided, before role', () => {
|
|
24
|
+
assert.equal(
|
|
25
|
+
buildWsUrl('wss://h/yjs', 'room1', 'write', 'p@ss/word'),
|
|
26
|
+
'wss://h/yjs?room=room1&p=p%40ss%2Fword&role=write',
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('buildWsUrl percent-encodes the room id', () => {
|
|
31
|
+
assert.equal(
|
|
32
|
+
buildWsUrl('wss://h/yjs', 'a b/c', 'write'),
|
|
33
|
+
'wss://h/yjs?room=a%20b%2Fc&role=write',
|
|
34
|
+
);
|
|
35
|
+
});
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
type LoadResponseData,
|
|
27
27
|
type SaveRequestData,
|
|
28
28
|
type SaveResponseData,
|
|
29
|
+
type SaveNotifyData,
|
|
30
|
+
type ExitData,
|
|
29
31
|
type SelectionChangedData,
|
|
30
32
|
type SignatureCancelData,
|
|
31
33
|
type SignatureCompleteData,
|
|
@@ -49,8 +51,15 @@ export interface EmbedHostHandlers {
|
|
|
49
51
|
onEditorReady?: (data: EditorHelloData) => void;
|
|
50
52
|
/** Editor requests bytes for `docId`. */
|
|
51
53
|
onLoadRequest?: (data: LoadRequestData) => Promise<LoadResponseData> | LoadResponseData;
|
|
52
|
-
/** Editor requests a save. */
|
|
54
|
+
/** Editor requests a save (WOPI-style, carries xlsx bytes). */
|
|
53
55
|
onSaveRequest?: (data: SaveRequestData) => Promise<SaveResponseData> | SaveResponseData;
|
|
56
|
+
/** Editor fired its lightweight save notification (Ctrl/Cmd+S or a
|
|
57
|
+
* host save command). Carries the full snapshot JSON; fire-and-forget.
|
|
58
|
+
* Mirror of the React `onSave` hook. */
|
|
59
|
+
onSaveNotify?: (data: SaveNotifyData) => void;
|
|
60
|
+
/** Editor is unmounting; carries the final snapshot. Mirror of the
|
|
61
|
+
* React `onExit` hook. */
|
|
62
|
+
onExit?: (data: ExitData) => void;
|
|
54
63
|
onSelectionChanged?: (data: SelectionChangedData) => void;
|
|
55
64
|
onSelectionFormatState?: (data: SelectionFormatStateData) => void;
|
|
56
65
|
onTelemetry?: (data: TelemetryEventData) => void;
|
|
@@ -182,6 +191,12 @@ export class EmbedHostTransport {
|
|
|
182
191
|
}
|
|
183
192
|
return;
|
|
184
193
|
}
|
|
194
|
+
case 'casual.save.notify':
|
|
195
|
+
this.handlers.onSaveNotify?.(env.data as SaveNotifyData);
|
|
196
|
+
return;
|
|
197
|
+
case 'casual.exit':
|
|
198
|
+
this.handlers.onExit?.(env.data as ExitData);
|
|
199
|
+
return;
|
|
185
200
|
case 'casual.selection.changed':
|
|
186
201
|
this.handlers.onSelectionChanged?.(env.data as SelectionChangedData);
|
|
187
202
|
return;
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
type LoadRequestData,
|
|
23
23
|
type SaveRequestData,
|
|
24
24
|
type SaveResponseData,
|
|
25
|
+
type SaveNotifyData,
|
|
26
|
+
type ExitData,
|
|
25
27
|
type SelectionChangedData,
|
|
26
28
|
type TelemetryEventData,
|
|
27
29
|
type CommandSetReadOnlyData,
|
|
@@ -145,6 +147,20 @@ export class EmbedTransport {
|
|
|
145
147
|
return this.request<SaveResponseData>('casual.save.request', req, timeoutMs, [req.bytes]);
|
|
146
148
|
}
|
|
147
149
|
|
|
150
|
+
/** Editor → Host: the user asked to save (Ctrl/Cmd+S or a host save
|
|
151
|
+
* command). Fire-and-forget snapshot notification — the lightweight
|
|
152
|
+
* mirror of the React `onSave` hook. The host persists however it
|
|
153
|
+
* likes; no response is awaited. */
|
|
154
|
+
sendSaveNotify(data: SaveNotifyData): void {
|
|
155
|
+
this.post('casual.save.notify', data);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Editor → Host: the editor is unmounting. Carries the final snapshot
|
|
159
|
+
* so the host can persist on exit. Mirror of the React `onExit` hook. */
|
|
160
|
+
sendExit(data: ExitData): void {
|
|
161
|
+
this.post('casual.exit', data);
|
|
162
|
+
}
|
|
163
|
+
|
|
148
164
|
/** Editor → Host: selection moved. Fire-and-forget. */
|
|
149
165
|
sendSelectionChanged(data: SelectionChangedData): void {
|
|
150
166
|
this.post('casual.selection.changed', data);
|