Skip to content

Commit 28472e3

Browse files
Add automatic responses or mocked sync service.
1 parent da53396 commit 28472e3

File tree

5 files changed

+232
-19
lines changed

5 files changed

+232
-19
lines changed

packages/web/src/worker/sync/MockSyncServiceTypes.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ export interface PendingRequest {
99
body: any;
1010
}
1111

12+
/**
13+
* Automatic response configuration
14+
*/
15+
export interface AutomaticResponseConfig {
16+
status: number;
17+
headers: Record<string, string>;
18+
bodyLines?: any[];
19+
}
20+
1221
/**
1322
* Message types for communication via MessagePort
1423
*/
@@ -22,13 +31,17 @@ export type MockSyncServiceMessage =
2231
headers: Record<string, string>;
2332
}
2433
| { type: 'pushBodyData'; requestId: string; pendingRequestId: string; data: string | ArrayBuffer | Uint8Array }
25-
| { type: 'completeResponse'; requestId: string; pendingRequestId: string };
34+
| { type: 'completeResponse'; requestId: string; pendingRequestId: string }
35+
| { type: 'setAutomaticResponse'; requestId: string; config: AutomaticResponseConfig | null }
36+
| { type: 'replyToAllPendingRequests'; requestId: string };
2637

2738
export type MockSyncServiceResponse =
2839
| { type: 'getPendingRequests'; requestId: string; requests: PendingRequest[] }
2940
| { type: 'createResponse'; requestId: string; success: boolean }
3041
| { type: 'pushBodyData'; requestId: string; success: boolean }
3142
| { type: 'completeResponse'; requestId: string; success: boolean }
43+
| { type: 'setAutomaticResponse'; requestId: string; success: boolean }
44+
| { type: 'replyToAllPendingRequests'; requestId: string; success: boolean; count: number }
3245
| { type: 'error'; requestId?: string; error: string };
3346

3447
/**

packages/web/src/worker/sync/MockSyncServiceWorker.ts

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { MockSyncServiceMessage, MockSyncServiceResponse } from './MockSyncServiceTypes';
2-
import { ActiveResponse, PendingRequest, PendingRequestInternal } from './MockSyncServiceTypes';
2+
import {
3+
ActiveResponse,
4+
AutomaticResponseConfig,
5+
PendingRequest,
6+
PendingRequestInternal
7+
} from './MockSyncServiceTypes';
38

49
/**
510
* Mock sync service implementation for shared worker environments.
@@ -10,6 +15,7 @@ export class MockSyncService {
1015
private pendingRequests: Map<string, PendingRequestInternal> = new Map();
1116
private activeResponses: Map<string, ActiveResponse> = new Map();
1217
private nextId = 0;
18+
private automaticResponse: AutomaticResponseConfig | null = null;
1319

1420
/**
1521
* Register a new pending request (called by WebRemote when a sync stream is requested).
@@ -60,7 +66,34 @@ export class MockSyncService {
6066
}
6167
});
6268

63-
// Return the promise - it will resolve when createResponse is called
69+
// If automatic response is configured, apply it immediately
70+
if (this.automaticResponse) {
71+
// Use setTimeout to ensure the response is created asynchronously
72+
// This prevents issues if the response creation happens synchronously
73+
setTimeout(() => {
74+
try {
75+
// Create response with automatic config
76+
this.createResponse(id, this.automaticResponse!.status, this.automaticResponse!.headers);
77+
78+
// Push body lines if provided
79+
if (this.automaticResponse!.bodyLines) {
80+
for (const line of this.automaticResponse!.bodyLines) {
81+
const lineStr = `${JSON.stringify(line)}\n`;
82+
const encoder = new TextEncoder();
83+
this.pushBodyData(id, encoder.encode(lineStr));
84+
}
85+
}
86+
87+
// Complete the response
88+
this.completeResponse(id);
89+
} catch (e) {
90+
// If automatic response fails, reject the promise
91+
rejectResponse!(e instanceof Error ? e : new Error(String(e)));
92+
}
93+
}, 0);
94+
}
95+
96+
// Return the promise - it will resolve when createResponse is called (or immediately if auto-response is set)
6497
return responsePromise;
6598
}
6699

@@ -172,6 +205,52 @@ export class MockSyncService {
172205
this.activeResponses.delete(pendingRequestId);
173206
}
174207
}
208+
209+
/**
210+
* Set the automatic response configuration.
211+
* When set, this will be used to automatically reply to all pending requests.
212+
*/
213+
setAutomaticResponse(config: AutomaticResponseConfig | null): void {
214+
this.automaticResponse = config;
215+
}
216+
217+
/**
218+
* Automatically reply to all pending requests using the automatic response configuration.
219+
* Returns the number of requests that were replied to.
220+
*/
221+
replyToAllPendingRequests(): number {
222+
if (!this.automaticResponse) {
223+
throw new Error('Automatic response not set. Call setAutomaticResponse first.');
224+
}
225+
226+
const pendingRequestIds = Array.from(this.pendingRequests.keys());
227+
let count = 0;
228+
229+
for (const requestId of pendingRequestIds) {
230+
try {
231+
// Create response with automatic config
232+
this.createResponse(requestId, this.automaticResponse.status, this.automaticResponse.headers);
233+
234+
// Push body lines if provided
235+
if (this.automaticResponse.bodyLines) {
236+
for (const line of this.automaticResponse.bodyLines) {
237+
const lineStr = `${JSON.stringify(line)}\n`;
238+
const encoder = new TextEncoder();
239+
this.pushBodyData(requestId, encoder.encode(lineStr));
240+
}
241+
}
242+
243+
// Complete the response
244+
this.completeResponse(requestId);
245+
count++;
246+
} catch (e) {
247+
// Skip requests that fail (might already be handled)
248+
continue;
249+
}
250+
}
251+
252+
return count;
253+
}
175254
}
176255

177256
/**
@@ -281,6 +360,41 @@ export function setupMockServiceMessageHandler(port: MessagePort) {
281360
}
282361
break;
283362
}
363+
case 'setAutomaticResponse': {
364+
try {
365+
service.setAutomaticResponse(message.config);
366+
port.postMessage({
367+
type: 'setAutomaticResponse',
368+
requestId: message.requestId,
369+
success: true
370+
} satisfies MockSyncServiceResponse);
371+
} catch (error) {
372+
port.postMessage({
373+
type: 'error',
374+
requestId: message.requestId,
375+
error: error instanceof Error ? error.message : String(error)
376+
} satisfies MockSyncServiceResponse);
377+
}
378+
break;
379+
}
380+
case 'replyToAllPendingRequests': {
381+
try {
382+
const count = service.replyToAllPendingRequests();
383+
port.postMessage({
384+
type: 'replyToAllPendingRequests',
385+
requestId: message.requestId,
386+
success: true,
387+
count
388+
} satisfies MockSyncServiceResponse);
389+
} catch (error) {
390+
port.postMessage({
391+
type: 'error',
392+
requestId: message.requestId,
393+
error: error instanceof Error ? error.message : String(error)
394+
} satisfies MockSyncServiceResponse);
395+
}
396+
break;
397+
}
284398
default: {
285399
const requestId =
286400
'requestId' in message && typeof message === 'object' && message !== null

packages/web/tests/multiple_tabs_iframe.test.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ function createIframeWithPowerSyncClient(
3030
dbFilename: string,
3131
identifier: string,
3232
vfs?: WASQLiteVFS,
33-
waitForConnection?: boolean
33+
waitForConnection?: boolean,
34+
configureMockResponses?: boolean
3435
): IframeClientResult {
3536
const iframe = document.createElement('iframe');
3637
// Make iframe visible for debugging
@@ -88,7 +89,7 @@ function createIframeWithPowerSyncClient(
8889
</div>
8990
<script type="module">
9091
import { setupPowerSyncInIframe } from '${modulePath}';
91-
setupPowerSyncInIframe('${dbFilename}', '${identifier}', ${vfs ? `'${vfs}'` : 'undefined'}, ${waitForConnection ? 'true' : 'false'});
92+
setupPowerSyncInIframe('${dbFilename}', '${identifier}', ${vfs ? `'${vfs}'` : 'undefined'}, ${waitForConnection ? 'true' : 'false'}, ${configureMockResponses ? 'true' : 'false'});
9293
</script>
9394
</body>
9495
</html>`;
@@ -296,7 +297,7 @@ function createIframeWithPowerSyncClient(
296297
*/
297298
function createMultipleTabsTest(vfs?: WASQLiteVFS) {
298299
const vfsName = vfs || 'IndexedDB';
299-
describe(`Multiple Tabs with Iframes (${vfsName})`, { sequential: true, timeout: 60_000 }, () => {
300+
describe(`Multiple Tabs with Iframes (${vfsName})`, { sequential: true, timeout: 30_000 }, () => {
300301
const dbFilename = `test-multi-tab-${uuid()}.db`;
301302

302303
// Number of tabs to create
@@ -305,8 +306,20 @@ function createMultipleTabsTest(vfs?: WASQLiteVFS) {
305306
const MIDDLE_TAB_INDEX = 49;
306307

307308
it('should handle opening and closing many tabs quickly', async () => {
309+
// Step 0: Create an iframe to set up PowerSync and configure mock responses (401)
310+
const setupIdentifier = `setup-${uuid()}`;
311+
const setupIframe = createIframeWithPowerSyncClient(dbFilename, setupIdentifier, vfs, false, true);
312+
onTestFinished(async () => {
313+
try {
314+
await setupIframe.cleanup();
315+
} catch (e) {
316+
// Ignore cleanup errors
317+
}
318+
});
319+
// Wait for the setup iframe to be ready (this ensures PowerSync is initialized and mock responses are configured)
320+
await setupIframe.ready;
308321
// Step 1: Open 100 tabs (don't wait for them to be ready)
309-
const tabResults: IframeClientResult[] = [];
322+
const tabResults: IframeClientResult[] = [setupIframe];
310323

311324
for (let i = 0; i < NUM_TABS; i++) {
312325
const identifier = `tab-${i}`;
@@ -323,7 +336,8 @@ function createMultipleTabsTest(vfs?: WASQLiteVFS) {
323336
});
324337
}
325338

326-
expect(tabResults.length).toBe(NUM_TABS);
339+
// Total iframes: 1 setup + NUM_TABS tabs
340+
expect(tabResults.length).toBe(NUM_TABS + 1);
327341

328342
// Verify all iframes are created (they're created immediately)
329343
for (const result of tabResults) {
@@ -333,32 +347,37 @@ function createMultipleTabsTest(vfs?: WASQLiteVFS) {
333347
// Step 2: Wait 1 second
334348
await new Promise((resolve) => setTimeout(resolve, 1000));
335349

336-
// Step 3: Close all tabs except the middle (50th) tab
350+
// Step 3: Close all tabs except the setup iframe (index 0) and the middle (50th) tab
351+
// The middle tab is at index 1 + MIDDLE_TAB_INDEX (since index 0 is the setup iframe)
352+
const middleTabArrayIndex = 1 + MIDDLE_TAB_INDEX;
337353
const tabsToClose: IframeClientResult[] = [];
338-
for (let i = 0; i < NUM_TABS; i++) {
339-
if (i !== MIDDLE_TAB_INDEX) {
354+
for (let i = 0; i < tabResults.length; i++) {
355+
// Skip the setup iframe (index 0) and the middle tab
356+
if (i !== 0 && i !== middleTabArrayIndex) {
340357
tabsToClose.push(tabResults[i]);
341358
}
342359
}
343360

344-
// Close all tabs except the middle one simultaneously (without waiting for ready)
361+
// Close all tabs except the setup iframe and middle one simultaneously (without waiting for ready)
345362
const closePromises = tabsToClose.map((result) => result.cleanup());
346363
await Promise.all(closePromises);
347364

348365
// Verify closed tabs are removed
349-
for (let i = 0; i < NUM_TABS; i++) {
350-
if (i !== MIDDLE_TAB_INDEX) {
366+
for (let i = 0; i < tabResults.length; i++) {
367+
if (i !== 0 && i !== middleTabArrayIndex) {
351368
expect(tabResults[i].iframe.isConnected).toBe(false);
352369
expect(document.body.contains(tabResults[i].iframe)).toBe(false);
353370
}
354371
}
355372

356-
// Verify the middle tab is still present
357-
expect(tabResults[MIDDLE_TAB_INDEX].iframe.isConnected).toBe(true);
358-
expect(document.body.contains(tabResults[MIDDLE_TAB_INDEX].iframe)).toBe(true);
373+
// Verify the setup iframe and middle tab are still present
374+
expect(tabResults[0].iframe.isConnected).toBe(true);
375+
expect(document.body.contains(tabResults[0].iframe)).toBe(true);
376+
expect(tabResults[middleTabArrayIndex].iframe.isConnected).toBe(true);
377+
expect(document.body.contains(tabResults[middleTabArrayIndex].iframe)).toBe(true);
359378

360379
// Step 4: Wait for the middle tab to be ready, then execute a test query to verify its DB is still functional
361-
const middleTabClient = await tabResults[MIDDLE_TAB_INDEX].ready;
380+
const middleTabClient = await tabResults[middleTabArrayIndex].ready;
362381
const queryResult = await middleTabClient.executeQuery('SELECT 1 as value');
363382

364383
// Verify the query result

packages/web/tests/utils/MockSyncServiceClient.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { StreamingSyncLine } from '@powersync/common';
22
import type {
3+
AutomaticResponseConfig,
34
MockSyncServiceMessage,
45
MockSyncServiceResponse,
56
PendingRequest
@@ -41,6 +42,18 @@ export interface MockSyncService {
4142
* The response must have been created first using createResponse.
4243
*/
4344
completeResponse(pendingRequestId: string): Promise<void>;
45+
46+
/**
47+
* Set the automatic response configuration.
48+
* When set, this will be used to automatically reply to all pending requests.
49+
*/
50+
setAutomaticResponse(config: AutomaticResponseConfig | null): Promise<void>;
51+
52+
/**
53+
* Automatically reply to all pending requests using the automatic response configuration.
54+
* Returns the number of requests that were replied to.
55+
*/
56+
replyToAllPendingRequests(): Promise<number>;
4457
}
4558

4659
/**
@@ -181,6 +194,35 @@ export async function getMockSyncServiceFromWorker(
181194
} satisfies MockSyncServiceMessage,
182195
'completeResponse'
183196
);
197+
},
198+
199+
async setAutomaticResponse(config: AutomaticResponseConfig | null): Promise<void> {
200+
const requestId = crypto.randomUUID();
201+
await sendMessage(
202+
{
203+
type: 'setAutomaticResponse',
204+
requestId,
205+
config
206+
} satisfies MockSyncServiceMessage,
207+
'setAutomaticResponse'
208+
);
209+
},
210+
211+
async replyToAllPendingRequests(): Promise<number> {
212+
const requestId = crypto.randomUUID();
213+
const response = await sendMessage<{
214+
type: 'replyToAllPendingRequests';
215+
requestId: string;
216+
success: boolean;
217+
count: number;
218+
}>(
219+
{
220+
type: 'replyToAllPendingRequests',
221+
requestId
222+
} satisfies MockSyncServiceMessage,
223+
'replyToAllPendingRequests'
224+
);
225+
return response.count;
184226
}
185227
};
186228
}

0 commit comments

Comments
 (0)