@syncular/testkit 0.1.2 → 0.2.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/README.md +210 -0
- package/dist/client.d.ts +68 -0
- package/dist/client.js +145 -0
- package/dist/clock.d.ts +24 -0
- package/dist/clock.js +32 -0
- package/dist/create-test-sync.d.ts +67 -0
- package/dist/create-test-sync.js +90 -0
- package/dist/faults.d.ts +52 -50
- package/dist/faults.js +74 -192
- package/dist/index.d.ts +17 -15
- package/dist/index.js +18 -15
- package/dist/react.d.ts +10 -0
- package/dist/react.js +41 -0
- package/dist/server.d.ts +44 -0
- package/dist/server.js +68 -0
- package/package.json +52 -29
- package/src/client.ts +218 -0
- package/src/clock.ts +45 -0
- package/src/create-test-sync.ts +158 -0
- package/src/faults.ts +77 -338
- package/src/index.ts +44 -14
- package/src/react.ts +46 -0
- package/src/server.ts +102 -0
- package/dist/audit.d.ts +0 -22
- package/dist/audit.d.ts.map +0 -1
- package/dist/audit.js +0 -40
- package/dist/audit.js.map +0 -1
- package/dist/client-bridge.d.ts +0 -77
- package/dist/client-bridge.d.ts.map +0 -1
- package/dist/client-bridge.js +0 -540
- package/dist/client-bridge.js.map +0 -1
- package/dist/deterministic.d.ts +0 -31
- package/dist/deterministic.d.ts.map +0 -1
- package/dist/deterministic.js +0 -69
- package/dist/deterministic.js.map +0 -1
- package/dist/disposable.d.ts +0 -10
- package/dist/disposable.d.ts.map +0 -1
- package/dist/disposable.js +0 -28
- package/dist/disposable.js.map +0 -1
- package/dist/faults.d.ts.map +0 -1
- package/dist/faults.js.map +0 -1
- package/dist/hono-node-server.d.ts +0 -20
- package/dist/hono-node-server.d.ts.map +0 -1
- package/dist/hono-node-server.js +0 -90
- package/dist/hono-node-server.js.map +0 -1
- package/dist/http-fixtures.d.ts +0 -32
- package/dist/http-fixtures.d.ts.map +0 -1
- package/dist/http-fixtures.js +0 -117
- package/dist/http-fixtures.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/project-scoped-tasks.d.ts +0 -40
- package/dist/project-scoped-tasks.d.ts.map +0 -1
- package/dist/project-scoped-tasks.js +0 -247
- package/dist/project-scoped-tasks.js.map +0 -1
- package/dist/realtime-ws.d.ts +0 -48
- package/dist/realtime-ws.d.ts.map +0 -1
- package/dist/realtime-ws.js +0 -203
- package/dist/realtime-ws.js.map +0 -1
- package/dist/runtime-process.d.ts +0 -14
- package/dist/runtime-process.d.ts.map +0 -1
- package/dist/runtime-process.js +0 -99
- package/dist/runtime-process.js.map +0 -1
- package/dist/sync-builders.d.ts +0 -48
- package/dist/sync-builders.d.ts.map +0 -1
- package/dist/sync-builders.js +0 -66
- package/dist/sync-builders.js.map +0 -1
- package/dist/sync-http.d.ts +0 -48
- package/dist/sync-http.d.ts.map +0 -1
- package/dist/sync-http.js +0 -43
- package/dist/sync-http.js.map +0 -1
- package/dist/sync-parse.d.ts +0 -8
- package/dist/sync-parse.d.ts.map +0 -1
- package/dist/sync-parse.js +0 -38
- package/dist/sync-parse.js.map +0 -1
- package/dist/sync-response.d.ts +0 -6
- package/dist/sync-response.d.ts.map +0 -1
- package/dist/sync-response.js +0 -19
- package/dist/sync-response.js.map +0 -1
- package/src/audit.ts +0 -74
- package/src/client-bridge.ts +0 -841
- package/src/deterministic.ts +0 -121
- package/src/disposable.ts +0 -50
- package/src/hono-node-server.ts +0 -144
- package/src/http-fixtures.ts +0 -187
- package/src/project-scoped-tasks.ts +0 -351
- package/src/realtime-ws.ts +0 -354
- package/src/runtime-process.ts +0 -159
- package/src/sync-builders.ts +0 -136
- package/src/sync-http.ts +0 -117
- package/src/sync-parse.ts +0 -86
- package/src/sync-response.ts +0 -43
package/src/faults.ts
CHANGED
|
@@ -1,349 +1,88 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
export
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
plan?: FaultPlanStep[];
|
|
33
|
-
onFail?: (operation: 'push' | 'pull' | 'fetch', error: Error) => void;
|
|
34
|
-
onSuccess?: (operation: 'push' | 'pull' | 'fetch') => void;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface FaultTransportState {
|
|
38
|
-
pushCount: number;
|
|
39
|
-
pullCount: number;
|
|
40
|
-
fetchCount: number;
|
|
41
|
-
failureCount: number;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface FaultTransportResult {
|
|
45
|
-
transport: SyncTransport;
|
|
46
|
-
getState: () => FaultTransportState;
|
|
47
|
-
reset: () => void;
|
|
48
|
-
setOptions: (options: Partial<FaultTransportOptions>) => void;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
interface ActiveFaultPlanStep {
|
|
52
|
-
operation: FaultPlanStep['operation'];
|
|
53
|
-
action: FaultTransportAction;
|
|
54
|
-
phase: FaultTransportPhase;
|
|
55
|
-
remaining: number;
|
|
56
|
-
failWith?: Error;
|
|
57
|
-
latencyMs?: number;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface PlannedFaultDecision {
|
|
61
|
-
action: FaultTransportAction;
|
|
62
|
-
phase: FaultTransportPhase;
|
|
63
|
-
error: Error;
|
|
64
|
-
latencyMs: number;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function withFaults(
|
|
68
|
-
baseTransport: SyncTransport,
|
|
69
|
-
options: FaultTransportOptions = {}
|
|
70
|
-
): FaultTransportResult {
|
|
71
|
-
let currentOptions = { ...options };
|
|
72
|
-
let currentPlan = createActivePlan(currentOptions.plan);
|
|
73
|
-
const state: FaultTransportState = {
|
|
74
|
-
pushCount: 0,
|
|
75
|
-
pullCount: 0,
|
|
76
|
-
fetchCount: 0,
|
|
77
|
-
failureCount: 0,
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
const defaultError = new Error('Simulated transport error');
|
|
81
|
-
|
|
82
|
-
const maybeDelay = async (extraLatencyMs = 0): Promise<void> => {
|
|
83
|
-
const latencyMs = (currentOptions.latencyMs ?? 0) + extraLatencyMs;
|
|
84
|
-
if (latencyMs > 0) {
|
|
85
|
-
await new Promise((resolve) => setTimeout(resolve, latencyMs));
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const shouldTargetOperation = (
|
|
90
|
-
operation: FaultTransportOperation
|
|
91
|
-
): boolean => {
|
|
92
|
-
const hasTargets =
|
|
93
|
-
currentOptions.failOnPush === true ||
|
|
94
|
-
currentOptions.failOnPull === true ||
|
|
95
|
-
currentOptions.failOnFetch === true;
|
|
96
|
-
|
|
97
|
-
if (!hasTargets) {
|
|
98
|
-
return true;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (operation === 'push') {
|
|
102
|
-
return currentOptions.failOnPush === true;
|
|
103
|
-
}
|
|
104
|
-
if (operation === 'pull') {
|
|
105
|
-
return currentOptions.failOnPull === true;
|
|
106
|
-
}
|
|
107
|
-
return currentOptions.failOnFetch === true;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const shouldFail = (
|
|
111
|
-
operation: FaultTransportOperation,
|
|
112
|
-
count: number
|
|
113
|
-
): boolean => {
|
|
114
|
-
if (!shouldTargetOperation(operation)) {
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
currentOptions.failAfter !== undefined &&
|
|
120
|
-
count >= currentOptions.failAfter
|
|
121
|
-
) {
|
|
122
|
-
return true;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (currentOptions.flaky !== undefined && currentOptions.flaky > 0) {
|
|
126
|
-
return Math.random() < currentOptions.flaky;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return false;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const getError = (): Error => currentOptions.failWith ?? defaultError;
|
|
133
|
-
|
|
134
|
-
const fail = (operation: FaultTransportOperation, error: Error): never => {
|
|
135
|
-
state.failureCount++;
|
|
136
|
-
currentOptions.onFail?.(operation, error);
|
|
137
|
-
throw error;
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
const recordSuccess = (operation: FaultTransportOperation): void => {
|
|
141
|
-
if (operation === 'push') {
|
|
142
|
-
state.pushCount++;
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
if (operation === 'pull') {
|
|
146
|
-
state.pullCount++;
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
state.fetchCount++;
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const takePlannedDecision = (
|
|
153
|
-
operation: FaultTransportOperation
|
|
154
|
-
): PlannedFaultDecision | null => {
|
|
155
|
-
const step = currentPlan.find(
|
|
156
|
-
(candidate) =>
|
|
157
|
-
candidate.remaining > 0 &&
|
|
158
|
-
(candidate.operation === 'any' || candidate.operation === operation)
|
|
159
|
-
);
|
|
160
|
-
if (!step) {
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
step.remaining -= 1;
|
|
165
|
-
return {
|
|
166
|
-
action: step.action,
|
|
167
|
-
phase: step.phase,
|
|
168
|
-
error: step.failWith ?? getError(),
|
|
169
|
-
latencyMs: step.latencyMs ?? 0,
|
|
170
|
-
};
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
const runWithFaults = async <Result>(
|
|
174
|
-
operation: FaultTransportOperation,
|
|
175
|
-
count: number,
|
|
176
|
-
run: () => Promise<Result>
|
|
177
|
-
): Promise<Result> => {
|
|
178
|
-
const plannedDecision = takePlannedDecision(operation);
|
|
179
|
-
await maybeDelay(plannedDecision?.latencyMs ?? 0);
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
plannedDecision?.action === 'fail' &&
|
|
183
|
-
plannedDecision.phase === 'before'
|
|
184
|
-
) {
|
|
185
|
-
return fail(operation, plannedDecision.error);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (!plannedDecision && shouldFail(operation, count)) {
|
|
189
|
-
return fail(operation, getError());
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const result = await run();
|
|
193
|
-
recordSuccess(operation);
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
plannedDecision?.action === 'fail' &&
|
|
197
|
-
plannedDecision.phase === 'after'
|
|
198
|
-
) {
|
|
199
|
-
return fail(operation, plannedDecision.error);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
currentOptions.onSuccess?.(operation);
|
|
203
|
-
return result;
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
const transport: SyncTransport = {
|
|
207
|
-
async sync(request, transportOptions) {
|
|
208
|
-
const operation: FaultTransportOperation = request.push ? 'push' : 'pull';
|
|
209
|
-
const count = operation === 'push' ? state.pushCount : state.pullCount;
|
|
210
|
-
|
|
211
|
-
return runWithFaults(operation, count, () =>
|
|
212
|
-
baseTransport.sync(request, transportOptions)
|
|
213
|
-
);
|
|
214
|
-
},
|
|
215
|
-
|
|
216
|
-
async fetchSnapshotChunk(
|
|
217
|
-
request: { chunkId: string },
|
|
218
|
-
transportOptions?: SyncTransportOptions
|
|
219
|
-
): Promise<Uint8Array> {
|
|
220
|
-
return runWithFaults('fetch', state.fetchCount, () =>
|
|
221
|
-
baseTransport.fetchSnapshotChunk(request, transportOptions)
|
|
222
|
-
);
|
|
223
|
-
},
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
return {
|
|
227
|
-
transport,
|
|
228
|
-
getState: () => ({ ...state }),
|
|
229
|
-
reset: () => {
|
|
230
|
-
state.pushCount = 0;
|
|
231
|
-
state.pullCount = 0;
|
|
232
|
-
state.fetchCount = 0;
|
|
233
|
-
state.failureCount = 0;
|
|
234
|
-
currentPlan = createActivePlan(currentOptions.plan);
|
|
235
|
-
},
|
|
236
|
-
setOptions: (newOptions) => {
|
|
237
|
-
currentOptions = { ...currentOptions, ...newOptions };
|
|
238
|
-
if ('plan' in newOptions) {
|
|
239
|
-
currentPlan = createActivePlan(currentOptions.plan);
|
|
240
|
-
}
|
|
241
|
-
},
|
|
1
|
+
/**
|
|
2
|
+
* Fault injection at the transport seam (test doctrine, REVISE B4).
|
|
3
|
+
*
|
|
4
|
+
* Canonical home: the PUBLIC app test kit. The private conformance harness
|
|
5
|
+
* re-exports from here so both arm the SAME fault vocabulary — one
|
|
6
|
+
* implementation, publishable dependency direction (testkit ships to npm;
|
|
7
|
+
* conformance stays private).
|
|
8
|
+
*
|
|
9
|
+
* The harness owns the loopback between a ClientInstance and a
|
|
10
|
+
* ServerInstance; scenarios arm faults on this controller and the next
|
|
11
|
+
* matching exchange misbehaves — deterministically, with no timers. The
|
|
12
|
+
* only randomness is the truncation offset, drawn from a seeded PRNG so
|
|
13
|
+
* every run of a scenario byte-truncates at the same place.
|
|
14
|
+
*
|
|
15
|
+
* Fault vocabulary (§2.3, §1.4, §5.1):
|
|
16
|
+
* - drop request — the request never reaches the server;
|
|
17
|
+
* - drop response — the server processed it, the ack is lost;
|
|
18
|
+
* - duplicate — the request is delivered twice (replayed bytes);
|
|
19
|
+
* - truncate — the response is cut short (decode-error surfacing);
|
|
20
|
+
* - segment faults — the same, at the segment download hop.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Deterministic PRNG (mulberry32) — seeded per scenario. */
|
|
24
|
+
export function seededRandom(seed: number): () => number {
|
|
25
|
+
let state = seed >>> 0;
|
|
26
|
+
return () => {
|
|
27
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
28
|
+
let t = state;
|
|
29
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
30
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
31
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
242
32
|
};
|
|
243
33
|
}
|
|
244
34
|
|
|
245
|
-
function
|
|
246
|
-
|
|
247
|
-
)
|
|
248
|
-
|
|
249
|
-
|
|
35
|
+
export function seedFromName(name: string): number {
|
|
36
|
+
let hash = 2166136261;
|
|
37
|
+
for (let i = 0; i < name.length; i++) {
|
|
38
|
+
hash ^= name.charCodeAt(i);
|
|
39
|
+
hash = Math.imul(hash, 16777619);
|
|
250
40
|
}
|
|
251
|
-
|
|
252
|
-
return plan.flatMap((step) => {
|
|
253
|
-
const remaining = step.repeat ?? 1;
|
|
254
|
-
if (remaining <= 0) {
|
|
255
|
-
return [];
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
return [
|
|
259
|
-
{
|
|
260
|
-
operation: step.operation,
|
|
261
|
-
action: step.action ?? 'fail',
|
|
262
|
-
phase: step.phase ?? 'before',
|
|
263
|
-
remaining,
|
|
264
|
-
failWith: step.failWith,
|
|
265
|
-
latencyMs: step.latencyMs,
|
|
266
|
-
},
|
|
267
|
-
];
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
export function createMockTransport(options?: {
|
|
272
|
-
pullResponse?: SyncPullResponse;
|
|
273
|
-
pushResponse?: SyncPushBatchResponse;
|
|
274
|
-
chunkData?: Uint8Array;
|
|
275
|
-
}): SyncTransport {
|
|
276
|
-
return {
|
|
277
|
-
async sync(request) {
|
|
278
|
-
const result: {
|
|
279
|
-
ok: true;
|
|
280
|
-
push?: SyncPushBatchResponse;
|
|
281
|
-
pull?: SyncPullResponse;
|
|
282
|
-
} = { ok: true };
|
|
283
|
-
|
|
284
|
-
if (request.push) {
|
|
285
|
-
result.push = options?.pushResponse ?? {
|
|
286
|
-
ok: true,
|
|
287
|
-
commits: request.push.commits.map((commit) => ({
|
|
288
|
-
ok: true,
|
|
289
|
-
clientCommitId: commit.clientCommitId,
|
|
290
|
-
status: 'applied',
|
|
291
|
-
results: commit.operations.map((_: unknown, i: number) => ({
|
|
292
|
-
opIndex: i,
|
|
293
|
-
status: 'applied',
|
|
294
|
-
})),
|
|
295
|
-
})),
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (request.pull) {
|
|
300
|
-
result.pull = options?.pullResponse ?? {
|
|
301
|
-
ok: true,
|
|
302
|
-
subscriptions: [],
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
return result;
|
|
307
|
-
},
|
|
308
|
-
|
|
309
|
-
async fetchSnapshotChunk(): Promise<Uint8Array> {
|
|
310
|
-
return options?.chunkData ?? new Uint8Array();
|
|
311
|
-
},
|
|
312
|
-
};
|
|
41
|
+
return hash >>> 0;
|
|
313
42
|
}
|
|
314
43
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
44
|
+
/** The error a faulted transport rejects with (client-observable). */
|
|
45
|
+
export class TransportFault extends Error {
|
|
46
|
+
override readonly name = 'TransportFault';
|
|
47
|
+
/** Client drivers surface transport losses under this code. */
|
|
48
|
+
readonly code = 'transport.lost';
|
|
320
49
|
}
|
|
321
50
|
|
|
322
|
-
export
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
51
|
+
export class TransportFaults {
|
|
52
|
+
/** Fail the next N sync requests before they reach the server. */
|
|
53
|
+
dropNextRequests = 0;
|
|
54
|
+
/** Deliver the next N sync requests, then lose their responses. */
|
|
55
|
+
dropNextResponses = 0;
|
|
56
|
+
/** Deliver the next sync request twice; return the second response. */
|
|
57
|
+
duplicateNextRequest = false;
|
|
58
|
+
/** Truncate the next sync response at a seeded offset. */
|
|
59
|
+
truncateNextResponse = false;
|
|
60
|
+
/** Fail the next N segment downloads before they reach the server. */
|
|
61
|
+
dropNextSegmentRequests = 0;
|
|
62
|
+
/** Truncate the next segment download at a seeded offset. */
|
|
63
|
+
truncateNextSegmentDownload = false;
|
|
64
|
+
/** Fail the next N signed-URL fetches (the CDN hop, §5.4). */
|
|
65
|
+
dropNextUrlFetches = 0;
|
|
66
|
+
/** Corrupt the next signed-URL fetch's bytes (§5.1 tamper). */
|
|
67
|
+
corruptNextUrlFetch = false;
|
|
68
|
+
|
|
69
|
+
readonly #random: () => number;
|
|
70
|
+
|
|
71
|
+
constructor(random: () => number) {
|
|
72
|
+
this.#random = random;
|
|
73
|
+
}
|
|
333
74
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
75
|
+
/** Seeded cut point: at least 1 byte kept, at least 1 byte removed. */
|
|
76
|
+
truncate(bytes: Uint8Array): Uint8Array {
|
|
77
|
+
const cut = 1 + Math.floor(this.#random() * (bytes.length - 1));
|
|
78
|
+
return bytes.slice(0, cut);
|
|
79
|
+
}
|
|
339
80
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
},
|
|
348
|
-
};
|
|
81
|
+
/** Flip one seeded byte — same length, different content address. */
|
|
82
|
+
corrupt(bytes: Uint8Array): Uint8Array {
|
|
83
|
+
const out = bytes.slice();
|
|
84
|
+
const index = Math.floor(this.#random() * out.length);
|
|
85
|
+
out[index] = (out[index] ?? 0) ^ 0xff;
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
349
88
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,44 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/testkit — the app-developer test kit (TODO §2).
|
|
3
|
+
*
|
|
4
|
+
* A small, documented surface over the shipped Syncular core: an in-memory
|
|
5
|
+
* server, N real clients on bun:sqlite through an in-process loopback (no
|
|
6
|
+
* HTTP), a shared virtual clock, per-client offline/online, and the
|
|
7
|
+
* conformance harness's transport-fault vocabulary. Designed for APP tests
|
|
8
|
+
* (readable, minimal) — not conformance (no driver/pairing machinery).
|
|
9
|
+
*
|
|
10
|
+
* The React helper lives behind `@syncular/testkit/react` so the core
|
|
11
|
+
* pulls in no React. See README.md for paste-ready examples.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
TestClient,
|
|
16
|
+
TestClientOptions,
|
|
17
|
+
} from './client';
|
|
18
|
+
export {
|
|
19
|
+
createVirtualClock,
|
|
20
|
+
DEFAULT_EPOCH_MS,
|
|
21
|
+
type VirtualClock,
|
|
22
|
+
} from './clock';
|
|
23
|
+
export {
|
|
24
|
+
type CreateTestSyncOptions,
|
|
25
|
+
createTestSync,
|
|
26
|
+
type TestClientOverrides,
|
|
27
|
+
type TestSync,
|
|
28
|
+
} from './create-test-sync';
|
|
29
|
+
// The transport-fault controller, re-exported from the conformance harness
|
|
30
|
+
// (never duplicated) — the SAME vocabulary the reference pairing arms.
|
|
31
|
+
export {
|
|
32
|
+
seededRandom,
|
|
33
|
+
seedFromName,
|
|
34
|
+
TransportFault,
|
|
35
|
+
TransportFaults,
|
|
36
|
+
} from './faults';
|
|
37
|
+
export {
|
|
38
|
+
allowAllScopes,
|
|
39
|
+
createTestServer,
|
|
40
|
+
DEFAULT_ACTOR,
|
|
41
|
+
DEFAULT_PARTITION,
|
|
42
|
+
type TestServer,
|
|
43
|
+
type TestServerOptions,
|
|
44
|
+
} from './server';
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React helper for the test kit (`@syncular/testkit/react`).
|
|
3
|
+
*
|
|
4
|
+
* `@syncular/react`'s `SyncProvider` already accepts any `SyncClient`, so
|
|
5
|
+
* mounting hooks against a test client is just:
|
|
6
|
+
*
|
|
7
|
+
* <SyncProvider client={testClient.api}>…</SyncProvider>
|
|
8
|
+
*
|
|
9
|
+
* This module is the tiny convenience over that pattern: `syncWrapper`
|
|
10
|
+
* returns the `wrapper` prop `@testing-library/react`'s `renderHook` /
|
|
11
|
+
* `render` want, so a test never hand-writes the provider element.
|
|
12
|
+
*
|
|
13
|
+
* import { renderHook, waitFor } from '@testing-library/react';
|
|
14
|
+
* import { useSyncQuery } from '@syncular/react';
|
|
15
|
+
* import { createTestSync } from '@syncular/testkit';
|
|
16
|
+
* import { syncWrapper } from '@syncular/testkit/react';
|
|
17
|
+
*
|
|
18
|
+
* const sync = await createTestSync({ schema });
|
|
19
|
+
* const client = await sync.client('a');
|
|
20
|
+
* const { result } = renderHook(
|
|
21
|
+
* () => useSyncQuery('SELECT * FROM notes'),
|
|
22
|
+
* { wrapper: syncWrapper(client) },
|
|
23
|
+
* );
|
|
24
|
+
*
|
|
25
|
+
* `react` is an OPTIONAL peer of the kit: import this subpath only from a
|
|
26
|
+
* test that already depends on React. The core (`@syncular/testkit`)
|
|
27
|
+
* pulls in no React.
|
|
28
|
+
*
|
|
29
|
+
* NOTE: this file uses `createElement` (no JSX) so the kit typechecks under
|
|
30
|
+
* the repo's `.ts`-only root tsconfig — same posture as `SyncProvider`.
|
|
31
|
+
*/
|
|
32
|
+
import { SyncProvider } from '@syncular/react';
|
|
33
|
+
import { createElement, type ReactNode } from 'react';
|
|
34
|
+
import type { TestClient } from './client';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the `wrapper` component `renderHook` / `render` take, providing the
|
|
38
|
+
* given test client to the Syncular hook tree. Pass a `TestClient` — its
|
|
39
|
+
* `.api` (the real `SyncClient`) is supplied to `SyncProvider`.
|
|
40
|
+
*/
|
|
41
|
+
export function syncWrapper(
|
|
42
|
+
client: TestClient,
|
|
43
|
+
): (props: { children: ReactNode }) => ReactNode {
|
|
44
|
+
return ({ children }) =>
|
|
45
|
+
createElement(SyncProvider, { client: client.api }, children);
|
|
46
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-memory test server: one `SqliteServerStorage` (`:memory:`), a
|
|
3
|
+
* `MemorySegmentStore`, and a `RealtimeHub`, all sharing the test's virtual
|
|
4
|
+
* clock. It is the SHIPPED `@syncular/server` core — the kit adds no
|
|
5
|
+
* server logic, it only wires the pieces an app would wire in production
|
|
6
|
+
* (minus the HTTP adapter). Clients reach it through the loopback seam in
|
|
7
|
+
* `client.ts`, calling `handleSyncRequest` / `handleSegmentDownload`
|
|
8
|
+
* directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { type ClientSchema, compileClientSchema } from '@syncular/client';
|
|
12
|
+
import {
|
|
13
|
+
createRealtimeHub,
|
|
14
|
+
MemorySegmentStore,
|
|
15
|
+
type RealtimeHub,
|
|
16
|
+
type ResolveScopes,
|
|
17
|
+
type ServerSchema,
|
|
18
|
+
SqliteServerStorage,
|
|
19
|
+
type SyncRequestContext,
|
|
20
|
+
} from '@syncular/server';
|
|
21
|
+
import type { VirtualClock } from './clock';
|
|
22
|
+
|
|
23
|
+
/** Default partition every actor lives in (apps override via options). */
|
|
24
|
+
export const DEFAULT_PARTITION = 'test';
|
|
25
|
+
/** Default actor a client is bound to when none is given. */
|
|
26
|
+
export const DEFAULT_ACTOR = 'test-actor';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `resolveScopes` that grants every value (`'*'`) for every scope variable
|
|
30
|
+
* the schema declares — the "no authorization, just make it converge"
|
|
31
|
+
* default an app test wants. Override with your own resolver to exercise
|
|
32
|
+
* scope-scoping / revocation.
|
|
33
|
+
*/
|
|
34
|
+
export function allowAllScopes(schema: ClientSchema): ResolveScopes {
|
|
35
|
+
const compiled = compileClientSchema(schema);
|
|
36
|
+
const variables = new Set<string>();
|
|
37
|
+
for (const table of compiled.tables.values()) {
|
|
38
|
+
for (const variable of table.scopeColumnByVariable.keys()) {
|
|
39
|
+
variables.add(variable);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const allowed: Record<string, string[]> = {};
|
|
43
|
+
for (const variable of variables) allowed[variable] = ['*'];
|
|
44
|
+
return () => allowed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TestServerOptions {
|
|
48
|
+
readonly schema: ClientSchema;
|
|
49
|
+
readonly clock: VirtualClock;
|
|
50
|
+
readonly partition: string;
|
|
51
|
+
/** Defaults to {@link allowAllScopes}. */
|
|
52
|
+
readonly resolveScopes?: ResolveScopes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The server half of a test sync. `ctxFor(actorId)` builds the per-request
|
|
57
|
+
* context the loopback transport feeds `handleSyncRequest`; `hub` is the
|
|
58
|
+
* realtime fanout every client's socket attaches to.
|
|
59
|
+
*/
|
|
60
|
+
export interface TestServer {
|
|
61
|
+
readonly storage: SqliteServerStorage;
|
|
62
|
+
readonly segments: MemorySegmentStore;
|
|
63
|
+
readonly hub: RealtimeHub;
|
|
64
|
+
readonly partition: string;
|
|
65
|
+
ctxFor(actorId: string): SyncRequestContext;
|
|
66
|
+
close(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createTestServer(options: TestServerOptions): TestServer {
|
|
70
|
+
const { schema, clock, partition } = options;
|
|
71
|
+
const serverSchema = schema as ServerSchema;
|
|
72
|
+
const storage = new SqliteServerStorage();
|
|
73
|
+
const segments = new MemorySegmentStore();
|
|
74
|
+
const resolveScopes = options.resolveScopes ?? allowAllScopes(schema);
|
|
75
|
+
const clockFn = () => clock.now();
|
|
76
|
+
const hub = createRealtimeHub({
|
|
77
|
+
schema: serverSchema,
|
|
78
|
+
storage,
|
|
79
|
+
resolveScopes,
|
|
80
|
+
clock: clockFn,
|
|
81
|
+
segments,
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
storage,
|
|
85
|
+
segments,
|
|
86
|
+
hub,
|
|
87
|
+
partition,
|
|
88
|
+
ctxFor: (actorId) => ({
|
|
89
|
+
partition,
|
|
90
|
+
actorId,
|
|
91
|
+
schema: serverSchema,
|
|
92
|
+
storage,
|
|
93
|
+
segments,
|
|
94
|
+
resolveScopes,
|
|
95
|
+
clock: clockFn,
|
|
96
|
+
realtime: hub,
|
|
97
|
+
}),
|
|
98
|
+
close: () => {
|
|
99
|
+
storage.db.close();
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/dist/audit.d.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
export interface RedactedAuditChange {
|
|
2
|
-
redaction?: {
|
|
3
|
-
payload?: unknown;
|
|
4
|
-
reason?: unknown;
|
|
5
|
-
};
|
|
6
|
-
rowJson?: unknown;
|
|
7
|
-
scopes?: unknown;
|
|
8
|
-
[key: string]: unknown;
|
|
9
|
-
}
|
|
10
|
-
export interface RedactedAuditDebugExport {
|
|
11
|
-
commits?: Array<{
|
|
12
|
-
changes?: RedactedAuditChange[];
|
|
13
|
-
[key: string]: unknown;
|
|
14
|
-
}>;
|
|
15
|
-
[key: string]: unknown;
|
|
16
|
-
}
|
|
17
|
-
export declare function assertAuditChangeRedacted(change: RedactedAuditChange, message?: string): void;
|
|
18
|
-
export declare function assertAuditJsonExcludes(value: unknown, forbiddenNeedles: readonly string[], message?: string): void;
|
|
19
|
-
export declare function assertAuditDebugExportRedacted(value: RedactedAuditDebugExport, forbiddenNeedles?: readonly string[], message?: string): void;
|
|
20
|
-
export declare function readJsonResponse<T>(response: Response): Promise<T>;
|
|
21
|
-
export declare function readTextResponse(response: Response): Promise<string>;
|
|
22
|
-
//# sourceMappingURL=audit.d.ts.map
|
package/dist/audit.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,mBAAmB;IAClC,SAAS,CAAC,EAAE;QACV,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB,MAAM,CAAC,EAAE,OAAO,CAAC;KAClB,CAAC;IACF,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,wBAAwB;IACvC,OAAO,CAAC,EAAE,KAAK,CAAC;QACd,OAAO,CAAC,EAAE,mBAAmB,EAAE,CAAC;QAChC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC,CAAC;IACH,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,SAAyC,GAC/C,IAAI,CAeN;AAED,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,OAAO,EACd,gBAAgB,EAAE,SAAS,MAAM,EAAE,EACnC,OAAO,SAAwC,GAC9C,IAAI,CAQN;AAED,wBAAgB,8BAA8B,CAC5C,KAAK,EAAE,wBAAwB,EAC/B,gBAAgB,GAAE,SAAS,MAAM,EAAO,EACxC,OAAO,SAA+C,GACrD,IAAI,CAON;AAED,wBAAsB,gBAAgB,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,CAAC,CAAC,CAExE;AAED,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC,CAE1E"}
|