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
2 changes: 2 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ The checked-in [`.env.example`](../.env.example) contains optional client observ
- `VITE_POSTHOG_KEY`
- `VITE_POSTHOG_HOST`

Two more build variables turn on the no-login mini round on `/ai-debate`: `VITE_DEMO_ENABLED=true` and `VITE_TURNSTILE_SITE_KEY` (Cloudflare Turnstile's public site key). Without both, the page shows its static sample round. The `demo-rebuttal` Edge Function has its own `DEMO_ENABLED` switch; see [supabase/README.md](../supabase/README.md#function-groups).

Production builds also read the Sentry build variables `SENTRY_AUTH_TOKEN`, `SENTRY_ORG` and `SENTRY_PROJECT`. Local builds leave them unset and skip source map upload. See [operations.md](operations.md#secrets).

The frontend Supabase environment is resolved by [`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts), not by those variables. Public browser configuration is distinct from server secrets.
Expand Down
80 changes: 80 additions & 0 deletions e2e/ai-debate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { test, expect, type Page, type Request } from '@playwright/test';

// The /ai-debate mini round with the demo-rebuttal Edge Function and Cloudflare
// Turnstile mocked. playwright.config.ts builds the dev server with
// VITE_DEMO_ENABLED=true and a test site key, so the live form shows.

const TURNSTILE_STUB = `window.turnstile = {
render(element, options) { setTimeout(() => options.callback('test-turnstile-token'), 0); return 'widget-1'; },
reset() {},
remove() {},
};`;

async function mockDemo(page: Page) {
const bodies: Array<Record<string, unknown>> = [];
await page.route('https://challenges.cloudflare.com/**', (route) => route.fulfill({ contentType: 'text/javascript', body: TURNSTILE_STUB }));
await page.route('**/functions/v1/demo-rebuttal', async (route) => {
const request: Request = route.request();
if (request.method() === 'OPTIONS') return route.fulfill({ status: 200, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*' } });
const body = request.postDataJSON() as Record<string, unknown>;
bodies.push(body);
const turn = body.sessionId ? 2 : 1;
return route.fulfill({
status: 200,
headers: { 'Access-Control-Allow-Origin': '*' },
contentType: 'application/json',
body: JSON.stringify({
sessionId: '11111111-1111-4111-8111-111111111111',
turn,
turnsLeft: turn === 1 ? 1 : 0,
rebuttal: turn === 1 ? 'Phones also let pupils reach their parents in an emergency.' : 'Office phones are slower when every second counts.',
judgeNote: turn === 1 ? 'You never say how a ban is enforced.' : 'Good answer; now weigh safety against focus.',
}),
});
});
return bodies;
}

test.describe('AI debate mini round', () => {
test('two turns against the mocked AI end in the trial CTA', async ({ page }) => {
const bodies = await mockDemo(page);
await page.goto('/ai-debate');
await expect(page.getByRole('heading', { level: 1, name: 'Debate an AI that argues back' })).toBeVisible();
await expect(page.getByRole('heading', { level: 2, name: 'Frequently asked questions' })).toBeVisible();

await page.getByLabel('Your argument').fill('Phones distract pupils in class and make bullying easier.');
await page.getByRole('button', { name: 'Get the AI’s rebuttal' }).click();
await expect(page.getByTestId('demo-rebuttal').first()).toHaveText('Phones also let pupils reach their parents in an emergency.');
await expect(page.getByTestId('demo-judge-note').first()).toContainText('You never say how a ban is enforced.');

await page.getByLabel('Your reply (one more turn)').fill('The school office phone covers emergencies.');
await page.getByRole('button', { name: 'Send reply' }).click();
await expect(page.getByTestId('demo-rebuttal').nth(1)).toHaveText('Office phones are slower when every second counts.');

// Two turns is the cap: no third reply box, and the trial CTA in its place.
await expect(page.getByLabel('Your reply (one more turn)')).toHaveCount(0);
await expect(page.getByRole('link', { name: 'Run a full timed round: start your 3-day trial' }).first()).toHaveAttribute('href', '/login');

expect(bodies[0]).toMatchObject({ turnstileToken: 'test-turnstile-token', side: 'for', motion: 'This House would ban smartphones in schools' });
expect(bodies[0].sessionId).toBeUndefined();
expect(bodies[1]).toMatchObject({
sessionId: '11111111-1111-4111-8111-111111111111',
previous: { argument: 'Phones distract pupils in class and make bullying easier.', rebuttal: 'Phones also let pupils reach their parents in an emergency.' },
});
});

test('a switched-off server falls back to the sample round', async ({ page }) => {
await page.route('https://challenges.cloudflare.com/**', (route) => route.fulfill({ contentType: 'text/javascript', body: TURNSTILE_STUB }));
await page.route('**/functions/v1/demo-rebuttal', (route) => route.fulfill({
status: 503,
headers: { 'Access-Control-Allow-Origin': '*' },
contentType: 'application/json',
body: JSON.stringify({ error: 'demo_disabled' }),
}));
await page.goto('/ai-debate');
await page.getByLabel('Your argument').fill('Phones distract pupils in class.');
await page.getByRole('button', { name: 'Get the AI’s rebuttal' }).click();
await expect(page.getByTestId('demo-sample')).toBeVisible();
await expect(page.getByLabel('Your argument')).toHaveCount(0);
});
});
3 changes: 3 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ export default defineConfig({
stderr: 'pipe',
env: {
VITE_E2E: '1',
// The /ai-debate mini round; e2e/ai-debate.spec.ts mocks Turnstile and the function.
VITE_DEMO_ENABLED: 'true',
VITE_TURNSTILE_SITE_KEY: 'e2e-turnstile-site-key',
...(process.env.E2E_ALLOW_PROD === '1' ? { VITE_E2E_ALLOW_PROD: '1' } : {}),
},
},
Expand Down
1 change: 1 addition & 0 deletions public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ DebateOwl runs at https://debateowl.com/. It is a separate product from debatela

- [Home](https://debateowl.com/): what DebateOwl is, the 8 prep modes, pricing and FAQ
- [Pricing](https://debateowl.com/debateowl-pricing): trial, Pro and what each includes
- [AI debate partner](https://debateowl.com/ai-debate): argue one side of a motion and an AI opponent rebuts it. One exchange and one follow-up are free with no account; full timed rounds need the 3-day trial, then $5/month + tax
- [About](https://debateowl.com/about): what DebateOwl is, who makes it and its former name
- [Contact](https://debateowl.com/contact): email hello@debateowl.com for support and feedback, privacy@debateowl.com for privacy and data requests
- [Press kit and facts](https://debateowl.com/press): a one-sentence definition, former name, maker, languages, pricing, logo downloads and what DebateOwl is not
Expand Down
Binary file added public/og/ai-debate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ function pageHtml(shell, page, assets) {
[/<meta name="twitter:title" content="[^"]*" \/>/, `<meta name="twitter:title" content="${escapeAttribute(head.title)}" />`, 'twitter:title'],
[/<meta name="twitter:description" content="[^"]*" \/>/, `<meta name="twitter:description" content="${escapeAttribute(head.description)}" />`, 'twitter:description'],
]) html = replaceOnce(html, pattern, value, label);
// A page with its own share image; the shell's width and height (1200x630) still apply.
if (head.image) {
const url = escapeAttribute(`${SITE_ORIGIN}${head.image.path}`);
const alt = escapeAttribute(head.image.alt);
for (const [pattern, value, label] of [
[/<meta property="og:image" content="[^"]*" \/>/, `<meta property="og:image" content="${url}" />`, 'og:image'],
[/<meta property="og:image:alt" content="[^"]*" \/>/, `<meta property="og:image:alt" content="${alt}" />`, 'og:image:alt'],
[/<meta name="twitter:image" content="[^"]*" \/>/, `<meta name="twitter:image" content="${url}" />`, 'twitter:image'],
[/<meta name="twitter:image:alt" content="[^"]*" \/>/, `<meta name="twitter:image:alt" content="${alt}" />`, 'twitter:image:alt'],
]) html = replaceOnce(html, pattern, value, label);
}
// The shell document is noindex (app, login, share). A public page puts the
// index directive back; a page without a canonical URL stays noindex.
const robots = head.canonicalPath
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const PrivacyPage = prerenderedPage("/privacy");
const AboutPage = prerenderedPage("/about");
const ContactPage = prerenderedPage("/contact");
const PressPage = prerenderedPage("/press");
const AiDebatePage = prerenderedPage("/ai-debate");
// The Hungarian and Spanish addresses in src/config/localized-routes.json.
const LOCALIZED_PAGES = LOCALIZED_PATHS.map((entry) => ({ ...entry, Page: prerenderedModule(localizedRouteLoaders[entry.path]) }));
const HomePage = lazyChunk(routeLoaders["/app"]);
Expand Down Expand Up @@ -480,6 +481,7 @@ function AppRoutes() {
<Route path="/about" element={<RouteContent><AboutPage /></RouteContent>} />
<Route path="/contact" element={<RouteContent><ContactPage /></RouteContent>} />
<Route path="/press" element={<RouteContent><PressPage /></RouteContent>} />
<Route path="/ai-debate" element={<RouteContent><AiDebatePage /></RouteContent>} />
<Route path="/expocit/sources" element={<RouteContent><SourcesPage /></RouteContent>} />
<Route path="/login" element={<RouteContent><LoginPage /></RouteContent>} />
<Route path={AUTH_CALLBACK_PATH} element={<RouteContent><AuthCallbackPage /></RouteContent>} />
Expand Down
Loading
Loading