Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions demos/react-supabase-todolist-tanstackdb/e2e/mocks/GuardBySync.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { FC, ReactNode } from 'react';

/**
* Mock GuardBySync that always renders children immediately.
* Bypasses the sync check for testing purposes.
*/
export const GuardBySync: FC<{ children: ReactNode; priority?: number }> = ({ children }) => {
// Always render children - skip the sync check
return <>{children}</>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import {
AbstractPowerSyncDatabase,
BaseObserver,
PowerSyncBackendConnector,
type PowerSyncCredentials
} from '@powersync/web';

// Mock user ID for testing
export const MOCK_USER_ID = 'test-user-123';

export type SupabaseConfig = {
supabaseUrl: string;
supabaseAnonKey: string;
powersyncUrl: string;
};

// Mock Session type matching Supabase's Session
type MockSession = {
access_token: string;
refresh_token: string;
expires_in: number;
expires_at: number;
token_type: string;
user: {
id: string;
email: string;
aud: string;
role: string;
created_at: string;
};
};

export type SupabaseConnectorListener = {
initialized: () => void;
sessionStarted: (session: MockSession) => void;
};

/**
* Mock SupabaseConnector for testing.
* Simulates an authenticated session without requiring actual Supabase credentials.
*/
export class SupabaseConnector extends BaseObserver<SupabaseConnectorListener> implements PowerSyncBackendConnector {
readonly client: MockSupabaseClient;
readonly config: SupabaseConfig;

ready: boolean;
currentSession: MockSession | null;

constructor() {
super();
this.config = {
supabaseUrl: 'https://mock.supabase.test',
powersyncUrl: 'https://mock.powersync.test',
supabaseAnonKey: 'mock-anon-key'
};

this.client = new MockSupabaseClient();

// Pre-authenticated session
this.currentSession = createMockSession();
this.ready = false;
}

async init() {
if (this.ready) {
return;
}

// Simulate session being loaded
this.ready = true;
this.iterateListeners((cb) => cb.initialized?.());

// Trigger session started since we're pre-authenticated
if (this.currentSession) {
this.iterateListeners((cb) => cb.sessionStarted?.(this.currentSession!));
}
}

async login(_username: string, _password: string) {
// Mock login - always succeeds
this.currentSession = createMockSession();
this.updateSession(this.currentSession);
}

async fetchCredentials(): Promise<PowerSyncCredentials> {
// Return mock credentials
return {
endpoint: this.config.powersyncUrl,
token: this.currentSession?.access_token ?? 'mock-token'
};
}

async uploadData(_database: AbstractPowerSyncDatabase): Promise<void> {
// Mock upload - do nothing
}

updateSession(session: MockSession | null) {
this.currentSession = session;
if (session) {
this.iterateListeners((cb) => cb.sessionStarted?.(session));
}
}
}

/**
* Creates a mock authenticated session
*/
function createMockSession(): MockSession {
return {
access_token: 'mock-access-token',
refresh_token: 'mock-refresh-token',
expires_in: 3600,
expires_at: Math.floor(Date.now() / 1000) + 3600,
token_type: 'bearer',
user: {
id: MOCK_USER_ID,
email: 'test@example.com',
aud: 'authenticated',
role: 'authenticated',
created_at: new Date().toISOString()
}
};
}

/**
* Mock Supabase client for testing
*/
class MockSupabaseClient {
auth = {
getSession: async () => ({
data: { session: createMockSession() },
error: null
}),
signInWithPassword: async (_credentials: { email: string; password: string }) => ({
data: { session: createMockSession() },
error: null
}),
signOut: async () => ({ error: null }),
onAuthStateChange: (_callback: (event: string, session: MockSession | null) => void) => {
return { data: { subscription: { unsubscribe: () => {} } } };
}
};

from(_table: string) {
return {
upsert: async (_record: unknown) => ({ error: null }),
update: (_data: unknown) => ({
eq: async (_column: string, _value: unknown) => ({ error: null })
}),
delete: () => ({
eq: async (_column: string, _value: unknown) => ({ error: null })
})
};
}
}
12 changes: 12 additions & 0 deletions demos/react-supabase-todolist-tanstackdb/e2e/test-setup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Re-export MOCK_USER_ID from the mock
export { MOCK_USER_ID } from './mocks/SupabaseConnector';

/**
* Sets up the test DOM structure matching the app's index.html
*/
export function setupTestDOM() {
document.body.innerHTML = `
<div id="app"></div>
`;
return document.getElementById('app')!;
}
195 changes: 195 additions & 0 deletions demos/react-supabase-todolist-tanstackdb/e2e/todo-lists.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { screen, waitFor } from '@testing-library/react';
import { act } from 'react';
import { Root, createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { App } from '../src/app/App';
import { MOCK_USER_ID, setupTestDOM } from './test-setup';

// Get access to the PowerSync database instance exposed on window
declare global {
interface Window {
_powersync: import('@powersync/web').PowerSyncDatabase;
}
}

describe('TodoLists E2E', () => {
let root: Root | null = null;
let container: HTMLElement;

beforeEach(async () => {
// Set up DOM
container = setupTestDOM();
await renderAppAndWaitForTodoListsScreen();
});

afterEach(async () => {
// Cleanup
if (root) {
root.unmount();
root = null;
}

// Clean up the PowerSync database if it exists
if (window._powersync) {
try {
await window._powersync.disconnectAndClear();
} catch (e) {
// Ignore errors during cleanup
}
}
});

/**
* Helper to render app and wait for the Todo Lists screen to appear
*/
async function renderAppAndWaitForTodoListsScreen() {
await act(async () => {
root = createRoot(container);
root.render(<App />);
});

// Wait for PowerSync to be initialized
await waitFor(
() => {
expect(window._powersync).toBeDefined();
},
{ timeout: 10000 }
);

// Wait for the Todo Lists screen to appear (app auto-navigates when authenticated)
await waitFor(
() => {
expect(screen.getByText('Todo Lists')).toBeTruthy();
},
{ timeout: 10000 }
);
}

/**
* Helper to insert a list and wait for it to appear
*/
async function insertList(name: string) {
const listId = crypto.randomUUID();

await act(async () => {
await window._powersync.execute(`INSERT INTO lists (id, name, owner_id, created_at) VALUES (?, ?, ?, ?)`, [
listId,
name,
MOCK_USER_ID,
new Date().toISOString()
]);
});

return listId;
}

/**
* Helper to insert a todo
*/
async function insertTodo(listId: string, description: string, completed: boolean = false) {
const todoId = crypto.randomUUID();

await act(async () => {
await window._powersync.execute(
`INSERT INTO todos (id, list_id, description, created_by, created_at, completed, completed_at, completed_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
todoId,
listId,
description,
MOCK_USER_ID,
new Date().toISOString(),
completed ? 1 : 0,
completed ? new Date().toISOString() : null,
completed ? MOCK_USER_ID : null
]
);
});

return todoId;
}

it('should load the app and show the Todo Lists screen', async () => {
// Verify we're on the Todo Lists page
expect(screen.getByText('Todo Lists')).toBeTruthy();
});

it('should display a list widget after inserting a list via PowerSync SQL', async () => {
const listName = 'My Shopping List';
await insertList(listName);

// Wait for the list widget to render with our list name
await waitFor(
() => {
expect(screen.getByText(listName)).toBeTruthy();
},
{ timeout: 10000 }
);

// Verify action buttons are present
expect(screen.getByRole('button', { name: /delete/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /proceed/i })).toBeTruthy();
});

it('should display multiple list widgets after inserting multiple lists', async () => {
// Insert multiple lists
await insertList('Groceries');
await insertList('Work Tasks');
await insertList('Personal Goals');

// Wait for all list widgets to render
await waitFor(
() => {
expect(screen.getByText('Groceries')).toBeTruthy();
expect(screen.getByText('Work Tasks')).toBeTruthy();
expect(screen.getByText('Personal Goals')).toBeTruthy();
},
{ timeout: 10000 }
);

// Verify we have 3 delete buttons (one per list)
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
expect(deleteButtons).toHaveLength(3);
});

it('should display list with correct todo counts (pending and completed)', async () => {
const listName = 'My Task List';
const listId = await insertList(listName);

// Insert todos - 2 incomplete, 1 completed
await insertTodo(listId, 'Buy groceries', false);
await insertTodo(listId, 'Call mom', false);
await insertTodo(listId, 'Finish report', true);

// Wait for the list widget to render with correct stats
await waitFor(
() => {
expect(screen.getByText(listName)).toBeTruthy();
// Should show "2 pending, 1 completed" in the description
expect(screen.getByText(/2 pending/i)).toBeTruthy();
expect(screen.getByText(/1 completed/i)).toBeTruthy();
},
{ timeout: 10000 }
);
});

it('should render list widgets with delete and navigate action buttons', async () => {
const listName = 'Test List';
await insertList(listName);

// Wait for the list widget with action buttons
await waitFor(
() => {
expect(screen.getByText(listName)).toBeTruthy();
expect(screen.getByRole('button', { name: /delete/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /proceed/i })).toBeTruthy();
},
{ timeout: 10000 }
);
});

it('should display the floating action button to add new lists', async () => {
// The FAB should be present - find by class
const fab = document.querySelector('.MuiFab-root');
expect(fab).not.toBeNull();
});
});
Loading