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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ coverage/
.vite/
.playwright-cli/

# Playwright artifacts
test-results/
playwright-report/
playwright/.cache/

# Deploy tool state
.vercel/
.netlify/
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,23 @@ The application requires a Supabase project for authentication and edge function
3. Deploy the Edge Function (see [Deployment](#deployment))
4. Add API keys as Supabase secrets

### iOS and iPadOS

The iOS/iPadOS app is a Capacitor shell around the Vite build.

```bash
npm run ios:build
npm run ios:open
```

The native bundle identifier is `com.adam.debatelab`, and the app URL scheme is `debatelab://`.

OAuth setup notes:

- In Google Cloud, keep the authorized redirect URI as the Supabase HTTPS callback, for example `https://<project-ref>.supabase.co/auth/v1/callback`.
- In Supabase Authentication URL Configuration, add the native callback `debatelab://auth/callback` as an allowed redirect URL.
- Do not add `debatelab://auth/callback` to Google Cloud Web OAuth redirect URIs; Google only accepts `http` or `https` there.

---

## Project Structure
Expand Down Expand Up @@ -1285,6 +1302,8 @@ jobs:
| `npm run dev` | Start dev server (port 8080) |
| `npm run build` | Production build to `dist/` |
| `npm run build:dev` | Development build with source maps |
| `npm run ios:build` | Build web assets and sync the iOS app |
| `npm run ios:open` | Open the native iOS project in Xcode |
| `npm run preview` | Preview production build |
| `npm run lint` | Run ESLint |
| `npm test` | Run tests once |
Expand Down
32 changes: 32 additions & 0 deletions capacitor.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
appId: 'com.adam.debatelab',
appName: 'DebateLab',
webDir: 'dist',
ios: {
scheme: 'DebateLab',
contentInset: 'automatic',
allowsLinkPreview: false,
},
server: {
iosScheme: 'capacitor',
},
plugins: {
Keyboard: {
resize: 'body',
style: 'DEFAULT',
},
SplashScreen: {
launchAutoHide: true,
backgroundColor: '#0f172a',
},
StatusBar: {
overlaysWebView: false,
style: 'DARK',
backgroundColor: '#0f172a',
},
},
};

export default config;
54 changes: 54 additions & 0 deletions e2e/authed-pages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import { stubSupabaseSession, setLocale } from './helpers/auth';

const LOCALES = ['en', 'es', 'hu'] as const;
const AUTH_ROUTES = [
{ path: '/app', name: 'home' },
{ path: '/app/generate', name: 'generate' },
{ path: '/app/attack', name: 'attack' },
{ path: '/app/brainstorm', name: 'brainstorm' },
{ path: '/app/factoids', name: 'factoids' },
{ path: '/app/ask', name: 'ask' },
{ path: '/app/library', name: 'library' },
{ path: '/app/history', name: 'history' },
{ path: '/app/timer', name: 'timer' },
{ path: '/app/flowchart', name: 'flowchart' },
{ path: '/app/settings', name: 'settings' },
] as const;

for (const locale of LOCALES) {
test.describe(`authed pages [${locale}]`, () => {
test.beforeEach(async ({ page }) => {
await setLocale(page, locale);
await stubSupabaseSession(page);
});

for (const route of AUTH_ROUTES) {
test(`${route.name} renders without runtime errors`, async ({ page }) => {
const consoleErrors: string[] = [];
page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`));
page.on('console', (msg) => {
if (msg.type() !== 'error') return;
const text = msg.text();
if (text.includes('Failed to load resource')) return;
if (text.includes('favicon')) return;
if (text.includes('net::ERR_')) return;
if (/^Failed to fetch(:|$)/.test(text)) return;
if (text.includes('Auth] hydrate')) return;
if (/^Warning:/.test(text)) return;
consoleErrors.push(`console: ${text}`);
});

await page.goto(route.path, { waitUntil: 'domcontentloaded' });
await expect(page.locator('body')).toBeVisible();

// ErrorBoundary fallback should never appear for a clean route render.
await expect(page.getByText('Something broke on this page')).toHaveCount(0);
// We were not bounced back to /login by RequireAuth.
expect(page.url()).not.toContain('/login');

expect(consoleErrors, consoleErrors.join('\n')).toEqual([]);
});
}
});
}
63 changes: 63 additions & 0 deletions e2e/helpers/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Page } from '@playwright/test';

const HOUR_SECONDS = 60 * 60;

export interface StubAuthOptions {
env?: 'production' | 'staging';
userId?: string;
email?: string;
}

/**
* Inject a stub Supabase session into localStorage so RequireAuth lets the
* test through. The JWT is bogus, so any API call from the page will fail —
* use this only for render smoke checks, not data-fetching assertions.
*/
export async function stubSupabaseSession(page: Page, opts: StubAuthOptions = {}) {
const env = opts.env ?? 'production';
const userId = opts.userId ?? '00000000-0000-0000-0000-000000000001';
const email = opts.email ?? 'e2e@example.com';
const expiresAt = Math.floor(Date.now() / 1000) + HOUR_SECONDS;
const session = {
access_token: 'stub-access',
refresh_token: 'stub-refresh',
expires_in: HOUR_SECONDS,
expires_at: expiresAt,
token_type: 'bearer',
user: {
id: userId,
aud: 'authenticated',
role: 'authenticated',
email,
app_metadata: { provider: 'google' },
user_metadata: { full_name: 'E2E Tester' },
created_at: new Date().toISOString(),
},
};

await page.addInitScript(({ key, value }) => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
/* ignore */
}
}, { key: `debate-auth-${env}`, value: session });
}

/**
* Set the persisted language so a page loads in a specific locale on first
* paint, without depending on the language toggle UI.
*/
export async function setLocale(page: Page, locale: 'en' | 'es' | 'hu') {
await page.addInitScript((lang) => {
const stored = {
state: { language: lang, outputLanguage: lang, theme: 'system' },
version: 0,
};
try {
window.localStorage.setItem('debate-trainer-settings', JSON.stringify(stored));
} catch {
/* ignore */
}
}, locale);
}
41 changes: 41 additions & 0 deletions e2e/public-pages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test';
import { setLocale } from './helpers/auth';

const LOCALES = ['en', 'es', 'hu'] as const;
const PUBLIC_ROUTES = [
{ path: '/', name: 'landing' },
{ path: '/terms', name: 'terms' },
{ path: '/privacy', name: 'privacy' },
{ path: '/login', name: 'login' },
] as const;

for (const locale of LOCALES) {
test.describe(`public pages [${locale}]`, () => {
test.beforeEach(async ({ page }) => {
await setLocale(page, locale);
});

for (const route of PUBLIC_ROUTES) {
test(`${route.name} renders without runtime errors`, async ({ page }) => {
const consoleErrors: string[] = [];
page.on('pageerror', (err) => consoleErrors.push(err.message));
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});

const response = await page.goto(route.path, { waitUntil: 'domcontentloaded' });
expect(response?.ok()).toBeTruthy();
await expect(page.locator('body')).toBeVisible();

const fatalErrors = consoleErrors.filter((msg) =>
!msg.includes('Failed to load resource') &&
!msg.includes('favicon') &&
!msg.includes('net::ERR_') &&
!/^Failed to fetch(:|$)/.test(msg) &&
!/^Warning:/.test(msg),
);
expect(fatalErrors, fatalErrors.join('\n')).toEqual([]);
});
}
});
}
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";

export default tseslint.config(
{ ignores: ["dist", "coverage"] },
{ ignores: ["dist", "coverage", "ios/App/App/public"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>DebateLab</title>
<meta name="description" content="DebateLab Application" />
<meta name="author" content="DebateLab" />
Expand Down
13 changes: 13 additions & 0 deletions ios/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
App/build
App/Pods
App/output
App/App/public
DerivedData
xcuserdata

# Cordova plugins for Capacitor
capacitor-cordova-ios-plugins

# Generated Config files
App/App/capacitor.config.json
App/App/config.xml
Loading
Loading