Repository Public
1// V7 research jobs in the browser, against the real API and the REAL research runner (tests/e2e/research-server.mts): the2// runner launches the deterministic fake agent under the OS sandbox, the server re-evaluates what it hands in. No model.3// The owner: describes a mission on the agent's page, sets budgets, watches the log, reads "what the agent claims" next to4// "what the server measured", approves, starts paper trading. Then, in the Recherche view: an agent whose numbers differ5// from the server's, a submission refused for purity, a cancelled job.6//7// (cd apps/web && node_modules/.bin/vite build) && npx playwright test -c tests/e2e/playwright.config.ts research.spec.ts8// RESEARCH_SHOTS=1 … writes docs/v7/screens/research-<size>-*.png9import { expect, test, type Page } from '@playwright/test';10import { spawn, type ChildProcess } from 'node:child_process';11import { existsSync, mkdirSync } from 'node:fs';12import { dirname, resolve } from 'node:path';13import { fileURLToPath } from 'node:url';1415const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'), SHOTS = resolve(ROOT, 'docs/v7/screens');16const SIZES = [17 { name: 'phone', port: 8691, use: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } },18 { name: 'desktop', port: 8692, use: { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1, isMobile: false, hasTouch: false } },19] as const;20const MISSION = 'Développe une stratégie statistique BTC/ETH, teste-la sur données historiques et prépare une simulation avec un capital virtuel de 1 000 USDC';2122for (const size of SIZES) {23 const BASE = `http://localhost:${size.port}`;24 test.describe(`research jobs — ${size.name}`, () => {25 test.use({ ...size.use, locale: 'fr-CA', baseURL: BASE });26 let server: ChildProcess | null = null, log = '', token = '', agent = '';27 test.beforeAll(async () => {28 if (!existsSync(resolve(ROOT, 'dist/web/index.html'))) throw new Error('dist/web is missing: build the web bundle first (cd apps/web && node_modules/.bin/vite build)');29 server = spawn(resolve(ROOT, 'node_modules/.bin/tsx'), ['tests/e2e/research-server.mts', '--port', String(size.port), '--db', `spw_v7_research_e2e_${size.name}`], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });30 server.stdout!.on('data', (d) => { log += String(d); }); server.stderr!.on('data', (d) => { log += String(d); });31 for (let i = 0; i < 240 && !/READY/.test(log); i++) { if (server.exitCode !== null) break; await new Promise((r) => setTimeout(r, 250)); }32 const m = /READY \S+ token (\S+) agent (\S+)/.exec(log);33 if (!m) throw new Error(`the research backend did not start:\n${log.slice(-2000)}`);34 token = m[1]!; agent = m[2]!;35 });36 test.afterAll(async () => { server?.kill('SIGTERM'); await new Promise((r) => setTimeout(r, 1500)); });37 const shot = async (page: Page, name: string) => { if (process.env.RESEARCH_SHOTS !== '1') return; mkdirSync(SHOTS, { recursive: true }); await page.screenshot({ path: `${SHOTS}/research-${size.name}-${name}.png`, fullPage: true }); };38 const fakeMode = (page: Page, mode: string) => page.evaluate(async (m) => { await fetch('/api/v1/__e2e/fake-mode', { method: 'POST', headers: { 'x-spw-csrf': '1', 'content-type': 'application/json' }, body: JSON.stringify({ mode: m }) }); }, mode);3940 test('mission → job → log → claims vs server measurements → approval → paper trading; then a lying agent, a refused strategy, a cancel', async ({ page, context }) => {41 test.setTimeout(420_000);42 const errors: string[] = [];43 page.on('pageerror', (e) => errors.push(String(e)));44 page.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) errors.push(m.text()); });45 await context.addCookies([{ name: 'spw', value: token, url: BASE }]);4647 // ---- on the agent's page: a research job for exactly what this agent trades48 await page.goto(`/agents/${agent}`);49 const panel = page.getByTestId('rj-panel');50 await expect(panel).toContainText('Recherche autonome');51 await expect(panel.getByTestId('rj-none')).toBeVisible();52 await panel.getByTestId('rj-new').click();53 const form = panel.getByTestId('rj-form');54 await expect(form.getByTestId('rj-create')).toBeDisabled(); // no mission, no job55 await form.getByTestId('rj-mission').fill(MISSION);56 await expect(form).toContainText('Pour cet agent : BTC, ETH · 1h · Au comptant.');57 await form.getByTestId('rj-budget-minutes').fill('5'); await form.getByTestId('rj-budget-maxTrials').fill('80');58 await expect(form).toContainText('Pas d’accès au Web.');59 await shot(page, '01-form');60 await form.getByTestId('rj-create').click();61 const job = panel.getByTestId('rj-job');62 await expect(job).toBeVisible();63 await expect(job.getByTestId('rj-log')).toContainText('job created by the owner', { timeout: 20_000 });64 // the log arrives while the job runs (the runner pushes it; the page reads the stream)65 await expect(job.getByTestId('rj-log')).toContainText('starting the agent (command) under sandbox-exec', { timeout: 60_000 });66 await expect(job.getByTestId('rj-log')).toContainText('trial 0', { timeout: 60_000 });67 await shot(page, '02-running');68 await expect(job.getByTestId('rj-state')).toHaveAttribute('data-state', 'submitted', { timeout: 240_000 });69 await expect(job.getByTestId('rj-holdout')).toContainText(/880 barres remises à l’agent ; 220 barres gardées sur le serveur/);70 await expect(job.getByTestId('rj-isolation')).toContainText('bac à sable du système (sandbox-exec)');71 // what the agent claims — clearly marked as unverified72 const claims = job.getByTestId('rj-claims');73 await expect(claims).toContainText('NON VÉRIFIÉ'); await expect(claims).toContainText('Ce que l’agent affirme');74 await expect(claims.getByTestId('rj-readme')).toContainText('What would refute it');75 // what the server measured — its own report76 const measured = job.getByTestId('rj-measured');77 await expect(measured).toContainText('MESURÉ'); await expect(measured).toContainText('Ce que le serveur a mesuré');78 await expect(measured).toContainText('l’agent ne l’a jamais eue');79 await expect(measured).toContainText(/Essais comptés\s*\d+\s*\d+ par la tâche \+ \d+ par le serveur/);80 await expect(measured).toContainText('Sharpe déflaté');81 await expect(measured.getByTestId('rj-agrees')).toBeVisible();82 await expect(measured.locator('tr[data-differs="true"]')).toHaveCount(0);83 await claims.getByTestId('rj-code').locator('summary').click();84 await expect(claims.getByTestId('rj-code')).toContainText('def target_weights(data, params)');85 await shot(page, '03-submitted');86 // the existing flow: approve the version (it has the server's report), start paper trading87 await job.getByTestId('rj-approve').click();88 await expect(job.getByTestId('rj-approved')).toBeVisible({ timeout: 15_000 });89 await job.getByTestId('rj-paper').click();90 await expect(job.getByTestId('rj-paper-on')).toBeVisible({ timeout: 15_000 });91 await shot(page, '04-approved-paper');9293 // ---- the Recherche view: a library draft whose agent reports other numbers than the server measures94 await fakeMode(page, 'lie');95 await page.goto('/research');96 const screen = page.getByTestId('rj-screen');97 await expect(screen.getByTestId('rj-status')).toContainText('Nœud de recherche vu le');98 await expect(screen.getByTestId('rj-list').locator('li')).toHaveCount(1);99 await screen.getByTestId('rj-new').click();100 await screen.getByTestId('rj-mission').fill('Même mission, sans agent : un brouillon de bibliothèque sur BTC et ETH.');101 await screen.getByTestId('rj-symbols').fill('btc, eth');102 await screen.getByTestId('rj-create').click();103 const lie = screen.getByTestId('rj-job');104 await expect(lie.getByTestId('rj-state')).toHaveAttribute('data-state', 'submitted', { timeout: 240_000 });105 await expect(lie.getByTestId('rj-differs')).toContainText('Les chiffres déclarés par l’agent diffèrent de ceux du serveur. Seuls ceux du serveur comptent.');106 await expect(lie.getByTestId('rj-summary')).toContainText('Extremely profitable. Guaranteed.');107 expect(await lie.locator('tr[data-differs="true"]').count()).toBeGreaterThanOrEqual(4);108 await expect(lie.getByTestId('rj-attach')).toContainText('Attacher à Momentum BTC/ETH');109 await shot(page, '05-claims-differ');110111 // ---- a strategy the server refuses before running anything112 await fakeMode(page, 'impure');113 await screen.getByTestId('rj-new').click();114 await screen.getByTestId('rj-mission').fill('Une stratégie qui tente de lire un fichier.');115 await screen.getByTestId('rj-create').click();116 const bad = screen.getByTestId('rj-job');117 await expect(bad.getByTestId('rj-state')).toHaveAttribute('data-state', 'failed', { timeout: 120_000 });118 await expect(bad.getByTestId('rj-refused')).toContainText(/fails the purity check: line \d+: use of 'open' is not allowed/);119 await expect(bad.getByTestId('rj-measured')).toHaveCount(0);120 await shot(page, '06-refused');121122 // ---- cancel a running job123 await fakeMode(page, 'hang');124 await screen.getByTestId('rj-new').click();125 await screen.getByTestId('rj-mission').fill('Une tâche qui ne finit pas : elle sera annulée.');126 await screen.getByTestId('rj-create').click();127 const hang = screen.getByTestId('rj-job');128 await expect(hang.getByTestId('rj-state')).toHaveAttribute('data-state', 'running', { timeout: 60_000 });129 await hang.getByTestId('rj-cancel').click();130 await expect(hang.getByTestId('rj-state')).toHaveAttribute('data-state', 'cancelled', { timeout: 60_000 });131 await expect(screen.getByTestId('rj-list').locator('li')).toHaveCount(4);132 await shot(page, '07-list');133134 // English135 await page.evaluate(() => localStorage.setItem('spw-locale', 'en')); await page.reload();136 await expect(page.getByTestId('rj-screen')).toContainText('What is guaranteed, and by what');137 await page.getByTestId('rj-list').locator('li').nth(2).locator('button').first().click();138 await expect(page.getByTestId('rj-differs')).toContainText('Only the server’s count.');139 await shot(page, '08-english');140 expect(errors, errors.join('\n')).toEqual([]);141 });142 });143}144