Skip to content

Commit 8815270

Browse files
committed
Merge branch 'main' into isolate-demos
2 parents 50d8d99 + 8bf5fd7 commit 8815270

File tree

10 files changed

+439
-31
lines changed

10 files changed

+439
-31
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { FC, ReactNode } from 'react';
2+
3+
/**
4+
* Mock GuardBySync that always renders children immediately.
5+
* Bypasses the sync check for testing purposes.
6+
*/
7+
export const GuardBySync: FC<{ children: ReactNode; priority?: number }> = ({ children }) => {
8+
// Always render children - skip the sync check
9+
return <>{children}</>;
10+
};
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import {
2+
AbstractPowerSyncDatabase,
3+
BaseObserver,
4+
PowerSyncBackendConnector,
5+
type PowerSyncCredentials
6+
} from '@powersync/web';
7+
8+
// Mock user ID for testing
9+
export const MOCK_USER_ID = 'test-user-123';
10+
11+
export type SupabaseConfig = {
12+
supabaseUrl: string;
13+
supabaseAnonKey: string;
14+
powersyncUrl: string;
15+
};
16+
17+
// Mock Session type matching Supabase's Session
18+
type MockSession = {
19+
access_token: string;
20+
refresh_token: string;
21+
expires_in: number;
22+
expires_at: number;
23+
token_type: string;
24+
user: {
25+
id: string;
26+
email: string;
27+
aud: string;
28+
role: string;
29+
created_at: string;
30+
};
31+
};
32+
33+
export type SupabaseConnectorListener = {
34+
initialized: () => void;
35+
sessionStarted: (session: MockSession) => void;
36+
};
37+
38+
/**
39+
* Mock SupabaseConnector for testing.
40+
* Simulates an authenticated session without requiring actual Supabase credentials.
41+
*/
42+
export class SupabaseConnector extends BaseObserver<SupabaseConnectorListener> implements PowerSyncBackendConnector {
43+
readonly client: MockSupabaseClient;
44+
readonly config: SupabaseConfig;
45+
46+
ready: boolean;
47+
currentSession: MockSession | null;
48+
49+
constructor() {
50+
super();
51+
this.config = {
52+
supabaseUrl: 'https://mock.supabase.test',
53+
powersyncUrl: 'https://mock.powersync.test',
54+
supabaseAnonKey: 'mock-anon-key'
55+
};
56+
57+
this.client = new MockSupabaseClient();
58+
59+
// Pre-authenticated session
60+
this.currentSession = createMockSession();
61+
this.ready = false;
62+
}
63+
64+
async init() {
65+
if (this.ready) {
66+
return;
67+
}
68+
69+
// Simulate session being loaded
70+
this.ready = true;
71+
this.iterateListeners((cb) => cb.initialized?.());
72+
73+
// Trigger session started since we're pre-authenticated
74+
if (this.currentSession) {
75+
this.iterateListeners((cb) => cb.sessionStarted?.(this.currentSession!));
76+
}
77+
}
78+
79+
async login(_username: string, _password: string) {
80+
// Mock login - always succeeds
81+
this.currentSession = createMockSession();
82+
this.updateSession(this.currentSession);
83+
}
84+
85+
async fetchCredentials(): Promise<PowerSyncCredentials> {
86+
// Return mock credentials
87+
return {
88+
endpoint: this.config.powersyncUrl,
89+
token: this.currentSession?.access_token ?? 'mock-token'
90+
};
91+
}
92+
93+
async uploadData(_database: AbstractPowerSyncDatabase): Promise<void> {
94+
// Mock upload - do nothing
95+
}
96+
97+
updateSession(session: MockSession | null) {
98+
this.currentSession = session;
99+
if (session) {
100+
this.iterateListeners((cb) => cb.sessionStarted?.(session));
101+
}
102+
}
103+
}
104+
105+
/**
106+
* Creates a mock authenticated session
107+
*/
108+
function createMockSession(): MockSession {
109+
return {
110+
access_token: 'mock-access-token',
111+
refresh_token: 'mock-refresh-token',
112+
expires_in: 3600,
113+
expires_at: Math.floor(Date.now() / 1000) + 3600,
114+
token_type: 'bearer',
115+
user: {
116+
id: MOCK_USER_ID,
117+
email: 'test@example.com',
118+
aud: 'authenticated',
119+
role: 'authenticated',
120+
created_at: new Date().toISOString()
121+
}
122+
};
123+
}
124+
125+
/**
126+
* Mock Supabase client for testing
127+
*/
128+
class MockSupabaseClient {
129+
auth = {
130+
getSession: async () => ({
131+
data: { session: createMockSession() },
132+
error: null
133+
}),
134+
signInWithPassword: async (_credentials: { email: string; password: string }) => ({
135+
data: { session: createMockSession() },
136+
error: null
137+
}),
138+
signOut: async () => ({ error: null }),
139+
onAuthStateChange: (_callback: (event: string, session: MockSession | null) => void) => {
140+
return { data: { subscription: { unsubscribe: () => {} } } };
141+
}
142+
};
143+
144+
from(_table: string) {
145+
return {
146+
upsert: async (_record: unknown) => ({ error: null }),
147+
update: (_data: unknown) => ({
148+
eq: async (_column: string, _value: unknown) => ({ error: null })
149+
}),
150+
delete: () => ({
151+
eq: async (_column: string, _value: unknown) => ({ error: null })
152+
})
153+
};
154+
}
155+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Re-export MOCK_USER_ID from the mock
2+
export { MOCK_USER_ID } from './mocks/SupabaseConnector';
3+
4+
/**
5+
* Sets up the test DOM structure matching the app's index.html
6+
*/
7+
export function setupTestDOM() {
8+
document.body.innerHTML = `
9+
<div id="app"></div>
10+
`;
11+
return document.getElementById('app')!;
12+
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { screen, waitFor } from '@testing-library/react';
2+
import { act } from 'react';
3+
import { Root, createRoot } from 'react-dom/client';
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5+
import { App } from '../src/app/App';
6+
import { MOCK_USER_ID, setupTestDOM } from './test-setup';
7+
8+
// Get access to the PowerSync database instance exposed on window
9+
declare global {
10+
interface Window {
11+
_powersync: import('@powersync/web').PowerSyncDatabase;
12+
}
13+
}
14+
15+
describe('TodoLists E2E', () => {
16+
let root: Root | null = null;
17+
let container: HTMLElement;
18+
19+
beforeEach(async () => {
20+
// Set up DOM
21+
container = setupTestDOM();
22+
await renderAppAndWaitForTodoListsScreen();
23+
});
24+
25+
afterEach(async () => {
26+
// Cleanup
27+
if (root) {
28+
root.unmount();
29+
root = null;
30+
}
31+
32+
// Clean up the PowerSync database if it exists
33+
if (window._powersync) {
34+
try {
35+
await window._powersync.disconnectAndClear();
36+
} catch (e) {
37+
// Ignore errors during cleanup
38+
}
39+
}
40+
});
41+
42+
/**
43+
* Helper to render app and wait for the Todo Lists screen to appear
44+
*/
45+
async function renderAppAndWaitForTodoListsScreen() {
46+
await act(async () => {
47+
root = createRoot(container);
48+
root.render(<App />);
49+
});
50+
51+
// Wait for PowerSync to be initialized
52+
await waitFor(
53+
() => {
54+
expect(window._powersync).toBeDefined();
55+
},
56+
{ timeout: 10000 }
57+
);
58+
59+
// Wait for the Todo Lists screen to appear (app auto-navigates when authenticated)
60+
await waitFor(
61+
() => {
62+
expect(screen.getByText('Todo Lists')).toBeTruthy();
63+
},
64+
{ timeout: 10000 }
65+
);
66+
}
67+
68+
/**
69+
* Helper to insert a list and wait for it to appear
70+
*/
71+
async function insertList(name: string) {
72+
const listId = crypto.randomUUID();
73+
74+
await act(async () => {
75+
await window._powersync.execute(`INSERT INTO lists (id, name, owner_id, created_at) VALUES (?, ?, ?, ?)`, [
76+
listId,
77+
name,
78+
MOCK_USER_ID,
79+
new Date().toISOString()
80+
]);
81+
});
82+
83+
return listId;
84+
}
85+
86+
/**
87+
* Helper to insert a todo
88+
*/
89+
async function insertTodo(listId: string, description: string, completed: boolean = false) {
90+
const todoId = crypto.randomUUID();
91+
92+
await act(async () => {
93+
await window._powersync.execute(
94+
`INSERT INTO todos (id, list_id, description, created_by, created_at, completed, completed_at, completed_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
95+
[
96+
todoId,
97+
listId,
98+
description,
99+
MOCK_USER_ID,
100+
new Date().toISOString(),
101+
completed ? 1 : 0,
102+
completed ? new Date().toISOString() : null,
103+
completed ? MOCK_USER_ID : null
104+
]
105+
);
106+
});
107+
108+
return todoId;
109+
}
110+
111+
it('should load the app and show the Todo Lists screen', async () => {
112+
// Verify we're on the Todo Lists page
113+
expect(screen.getByText('Todo Lists')).toBeTruthy();
114+
});
115+
116+
it('should display a list widget after inserting a list via PowerSync SQL', async () => {
117+
const listName = 'My Shopping List';
118+
await insertList(listName);
119+
120+
// Wait for the list widget to render with our list name
121+
await waitFor(
122+
() => {
123+
expect(screen.getByText(listName)).toBeTruthy();
124+
},
125+
{ timeout: 10000 }
126+
);
127+
128+
// Verify action buttons are present
129+
expect(screen.getByRole('button', { name: /delete/i })).toBeTruthy();
130+
expect(screen.getByRole('button', { name: /proceed/i })).toBeTruthy();
131+
});
132+
133+
it('should display multiple list widgets after inserting multiple lists', async () => {
134+
// Insert multiple lists
135+
await insertList('Groceries');
136+
await insertList('Work Tasks');
137+
await insertList('Personal Goals');
138+
139+
// Wait for all list widgets to render
140+
await waitFor(
141+
() => {
142+
expect(screen.getByText('Groceries')).toBeTruthy();
143+
expect(screen.getByText('Work Tasks')).toBeTruthy();
144+
expect(screen.getByText('Personal Goals')).toBeTruthy();
145+
},
146+
{ timeout: 10000 }
147+
);
148+
149+
// Verify we have 3 delete buttons (one per list)
150+
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
151+
expect(deleteButtons).toHaveLength(3);
152+
});
153+
154+
it('should display list with correct todo counts (pending and completed)', async () => {
155+
const listName = 'My Task List';
156+
const listId = await insertList(listName);
157+
158+
// Insert todos - 2 incomplete, 1 completed
159+
await insertTodo(listId, 'Buy groceries', false);
160+
await insertTodo(listId, 'Call mom', false);
161+
await insertTodo(listId, 'Finish report', true);
162+
163+
// Wait for the list widget to render with correct stats
164+
await waitFor(
165+
() => {
166+
expect(screen.getByText(listName)).toBeTruthy();
167+
// Should show "2 pending, 1 completed" in the description
168+
expect(screen.getByText(/2 pending/i)).toBeTruthy();
169+
expect(screen.getByText(/1 completed/i)).toBeTruthy();
170+
},
171+
{ timeout: 10000 }
172+
);
173+
});
174+
175+
it('should render list widgets with delete and navigate action buttons', async () => {
176+
const listName = 'Test List';
177+
await insertList(listName);
178+
179+
// Wait for the list widget with action buttons
180+
await waitFor(
181+
() => {
182+
expect(screen.getByText(listName)).toBeTruthy();
183+
expect(screen.getByRole('button', { name: /delete/i })).toBeTruthy();
184+
expect(screen.getByRole('button', { name: /proceed/i })).toBeTruthy();
185+
},
186+
{ timeout: 10000 }
187+
);
188+
});
189+
190+
it('should display the floating action button to add new lists', async () => {
191+
// The FAB should be present - find by class
192+
const fab = document.querySelector('.MuiFab-root');
193+
expect(fab).not.toBeNull();
194+
});
195+
});

0 commit comments

Comments
 (0)