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: 3 additions & 2 deletions apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp

## Layout

- `src/app/`: routes. `sign-in.tsx`, and `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You.
- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; and the form sheets `debates.tsx` (switcher), `new-debate.tsx` and `delete-account.tsx`, declared in the root `_layout.tsx`.
- `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview.
- `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n.
- `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror.
- One-off mode is session-only and never written, as on the website. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored.
- `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts.
- `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs.

Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@react-native-async-storage/async-storage": "2.2.0",
"@supabase/supabase-js": "^2.49.1",
"aes-js": "^3.1.2",
"date-fns": "^3.6.0",
"expo": "~57.0.27",
"expo-constants": "~57.0.21",
"expo-crypto": "~57.0.3",
Expand Down
87 changes: 85 additions & 2 deletions apps/mobile/scripts/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,21 @@
* npm run -w @debatelab/mobile export:web
* PORT=<free port> npx tsx apps/mobile/scripts/screenshots.ts
*
* ONLY=iphone-light-en limits a run to the combinations whose name starts with it.
*
* Signed-in screens need a staging session. With STAGING_DEMO_CODE set, the
* script signs in once through the real sign-in screen and also checks that a
* cold start without a network keeps the user signed in. The deployed Edge
* Functions only accept browser calls from a few origins, so for that run use
* PORT=18080 (http://127.0.0.1:18080). Never print the code or put it in a
* screenshot.
*
* Signed in, the app applies the account's saved language and theme, which
* would override the locale and colour scheme under test. Screenshot runs
* therefore answer the app's user_settings reads with the run's language and
* the System theme; nothing is written. The Debate tab shows the account's
* real Current Debate, and its empty state comes from answering the
* current_debates read with no row. The Debate tab is shot once per side view.
*/
import { chromium, type Browser, type BrowserContextOptions, type Page } from '@playwright/test';
import { createReadStream, existsSync, statSync } from 'node:fs';
Expand All @@ -28,6 +37,7 @@ const DIST = join(__dirname, '../dist');
const OUT = join(__dirname, '../../../.verify/mobile-shots');
const PORT = Number(process.env.PORT);
const DEMO_CODE = process.env.STAGING_DEMO_CODE?.trim();
const ONLY = process.env.ONLY?.trim() ?? '';
const ORIGIN = `http://127.0.0.1:${PORT}`;
const STAGING_HOST = new URL(SUPABASE_PROJECTS.staging.url).host;
const SESSION_KEY = `sb-${STAGING_HOST.split('.')[0]}-auth-token`;
Expand All @@ -38,7 +48,8 @@ const DEVICES = {
} as const;
const SCHEMES = ['light', 'dark'] as const;
const LOCALES = { en: 'en-GB', hu: 'hu-HU' } as const;
const SIGNED_IN_ROUTES = { debate: '/', prepare: '/prepare', practice: '/practice', library: '/library', you: '/you' };
const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', library: '/library', you: '/you' };
const REST = `https://${STAGING_HOST}/rest/v1`;

const TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
Expand Down Expand Up @@ -83,6 +94,64 @@ async function shoot(page: Page, name: string) {
await page.screenshot({ path: join(OUT, `${name}.png`), mask: [page.getByTestId('account-email')] });
}

/** Answers user_settings reads with the run's language and the System theme (see the header). */
async function pinSettings(page: Page, language: string) {
await page.route(`${REST}/user_settings*`, async (route) => {
if (route.request().method() !== 'GET') return route.continue();
const response = await route.fetch();
const pin = (row: Record<string, unknown>) => ({
...row,
...('language' in row ? { language } : {}),
...('output_language' in row ? { output_language: language } : {}),
...('theme' in row ? { theme: 'system' } : {}),
});
const body: unknown = await response.json();
const json = Array.isArray(body)
? body.map((row) => pin(row as Record<string, unknown>))
: body && typeof body === 'object' ? pin(body as Record<string, unknown>) : body;
await route.fulfill({ response, json });
});
}

/** Waits until the screen's data has arrived, so no spinner is caught mid-load. */
async function settle(page: Page) {
await page.waitForLoadState('networkidle');
await page.waitForFunction(() => document.querySelectorAll('[role="progressbar"]').length === 0, undefined, { timeout: 15_000 });
}

async function shootDebateScreens(page: Page, prefix: string) {
await page.goto(`${ORIGIN}/`);
await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 });
await page.waitForFunction(() => !document.querySelector('[data-testid="debate-counts"]')?.textContent?.includes('–'));
await settle(page);
await shoot(page, `${prefix}-debate`);
// Both sides' work is already loaded, so the Against view needs no new request.
await page.getByRole('tab', { name: /^(Against|Ellene|En contra)$/ }).click();
await shoot(page, `${prefix}-debate-con`);

await page.getByTestId('current-debate-pill').click();
await page.getByTestId('debates-sheet').waitFor();
await settle(page);
// Let the sheet finish sliding in.
await page.waitForTimeout(700);
await shoot(page, `${prefix}-debates-sheet`);

await page.getByRole('button', { name: /^(New debate|Új vita|Nuevo debate)$/ }).click();
await page.getByTestId('new-debate-sheet').waitFor();
await page.waitForTimeout(700);
await shoot(page, `${prefix}-new-debate`);
}

async function shootEmptyDebate(page: Page, prefix: string) {
await page.route(`${REST}/current_debates*`, (route) =>
route.fulfill({ status: 200, contentType: 'application/json', json: [] }),
);
await page.goto(`${ORIGIN}/`);
await page.getByTestId('current-debate-pill').waitFor({ timeout: 30_000 });
await settle(page);
await shoot(page, `${prefix}-debate-empty`);
}

async function signIn(browser: Browser, problems: string[]): Promise<StorageState> {
const context = await browser.newContext({ ...DEVICES.iphone, locale: LOCALES.en });
const page = await context.newPage();
Expand Down Expand Up @@ -171,6 +240,7 @@ async function main() {
for (const scheme of SCHEMES) {
for (const [language, locale] of Object.entries(LOCALES)) {
const prefix = `${device}-${scheme}-${language}`;
if (!prefix.startsWith(ONLY)) continue;
const signedOut = await browser.newContext({ ...options, colorScheme: scheme, locale });
const page = await signedOut.newPage();
watchErrors(page, problems, prefix);
Expand All @@ -189,12 +259,25 @@ async function main() {
const signedIn = await browser.newContext({ ...options, colorScheme: scheme, locale, storageState: session });
const tabPage = await signedIn.newPage();
watchErrors(tabPage, problems, `${prefix} signed in`);
await pinSettings(tabPage, language);
await shootDebateScreens(tabPage, prefix).catch(async (error: unknown) => {
await shoot(tabPage, `${prefix}-failed`);
throw error;
});
shots += 4;
for (const [name, route] of Object.entries(SIGNED_IN_ROUTES)) {
await tabPage.goto(`${ORIGIN}${route}`);
await tabPage.getByRole('tablist').waitFor();
await tabPage.getByRole('tablist').first().waitFor();
if (name === 'you') await tabPage.getByTestId('subscription-tier').waitFor({ timeout: 30_000 });
await settle(tabPage);
await shoot(tabPage, `${prefix}-${name}`);
shots += 1;
}
const emptyPage = await signedIn.newPage();
watchErrors(emptyPage, problems, `${prefix} empty`);
await pinSettings(emptyPage, language);
await shootEmptyDebate(emptyPage, prefix);
shots += 1;
await signedIn.close();
}
}
Expand Down
Loading
Loading