Add research workspace and graphing flows

This commit is contained in:
2026-03-07 16:52:35 -05:00
parent db01f207a5
commit 62bacdf104
37 changed files with 5494 additions and 434 deletions

View File

@@ -1,17 +1,51 @@
import { expect, test } from '@playwright/test';
import { expect, test, type Page } from '@playwright/test';
const PASSWORD = 'Sup3rSecure!123';
test('redirects protected routes to sign in and preserves the return path', async ({ page }) => {
await page.goto('/analysis?ticker=nvda');
test.describe.configure({ mode: 'serial' });
function createDeferred() {
let resolve: (() => void) | null = null;
const promise = new Promise<void>((done) => {
resolve = done;
});
return {
promise,
resolve: () => resolve?.()
};
}
async function gotoAuthPage(page: Page, path: string) {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle');
}
test('preserves the return path while switching between auth screens and shows the expected controls', async ({ page }) => {
await gotoAuthPage(page, '/auth/signin?next=%2Fanalysis%3Fticker%3DNVDA');
await expect(page).toHaveURL(/\/auth\/signin\?/);
await expect(page.getByRole('heading', { name: 'Secure Sign In' })).toBeVisible();
expect(new URL(page.url()).searchParams.get('next')).toBe('/analysis?ticker=nvda');
await expect(page.getByText('Use email/password or request a magic link.')).toBeVisible();
await expect(page.locator('input[autocomplete="email"]')).toBeVisible();
await expect(page.locator('input[autocomplete="current-password"]')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in with password' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Send magic link' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Create one' })).toHaveAttribute('href', '/auth/signup?next=%2Fanalysis%3Fticker%3DNVDA');
await page.getByRole('link', { name: 'Create one' }).click();
await expect(page).toHaveURL(/\/auth\/signup\?next=%2Fanalysis%3Fticker%3DNVDA$/);
await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible();
await expect(page.getByText('Set up your operator profile to access portfolio and filings intelligence.')).toBeVisible();
await expect(page.locator('input[autocomplete="name"]')).toBeVisible();
await expect(page.locator('input[autocomplete="email"]')).toBeVisible();
await expect(page.locator('input[autocomplete="new-password"]').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Create account' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/auth/signin?next=%2Fanalysis%3Fticker%3DNVDA');
});
test('shows client-side validation when signup passwords do not match', async ({ page }) => {
await page.goto('/auth/signup');
await gotoAuthPage(page, '/auth/signup');
await page.locator('input[autocomplete="name"]').fill('Playwright User');
await page.locator('input[autocomplete="email"]').fill('mismatch@example.com');
@@ -22,17 +56,57 @@ test('shows client-side validation when signup passwords do not match', async ({
await expect(page.getByText('Passwords do not match.')).toBeVisible();
});
test('creates a new account and lands on the command center', async ({ page }) => {
const email = `playwright-${Date.now()}@example.com`;
test('shows loading affordances while sign-in is in flight', async ({ page }) => {
const gate = createDeferred();
await page.goto('/auth/signup');
await page.route('**/api/auth/sign-in/email', async (route) => {
await gate.promise;
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ message: 'Invalid credentials' })
});
});
await gotoAuthPage(page, '/auth/signin');
await page.locator('input[autocomplete="email"]').fill('playwright@example.com');
await page.locator('input[autocomplete="current-password"]').fill(PASSWORD);
const submitButton = page.getByRole('button', { name: 'Sign in with password' });
const magicLinkButton = page.getByRole('button', { name: 'Send magic link' });
await submitButton.click();
await expect(page.getByRole('button', { name: 'Signing in...' })).toBeDisabled();
await expect(magicLinkButton).toBeDisabled();
gate.resolve();
await expect(page.getByText('Invalid credentials')).toBeVisible();
});
test('shows loading affordances while sign-up is in flight', async ({ page }) => {
const gate = createDeferred();
await page.route('**/api/auth/sign-up/email', async (route) => {
await gate.promise;
await route.fulfill({
status: 409,
contentType: 'application/json',
body: JSON.stringify({ message: 'Email already exists' })
});
});
await gotoAuthPage(page, '/auth/signup');
await page.locator('input[autocomplete="name"]').fill('Playwright User');
await page.locator('input[autocomplete="email"]').fill(email);
await page.locator('input[autocomplete="email"]').fill(`playwright-${Date.now()}@example.com`);
await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD);
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page).toHaveURL(/\/$/);
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible();
await expect(page.getByText('Quick Links')).toBeVisible();
await expect(page.getByRole('button', { name: 'Creating account...' })).toBeDisabled();
gate.resolve();
await expect(page.getByText('Email already exists')).toBeVisible();
});

View File

@@ -0,0 +1,4 @@
Supply-chain diligence notes:
- Channel checks remain constructive.
- Gross margin watchpoint is still mix-driven.
- Need follow-up on inventory normalization pace.

255
e2e/graphing.spec.ts Normal file
View File

@@ -0,0 +1,255 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test';
const PASSWORD = 'Sup3rSecure!123';
function toSlug(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
}
async function signUp(page: Page, testInfo: TestInfo) {
const email = `playwright-graphing-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`;
await page.goto('/auth/signup');
await page.locator('input[autocomplete="name"]').fill('Playwright Graphing User');
await page.locator('input[autocomplete="email"]').fill(email);
await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD);
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 });
await expect(page).toHaveURL(/\/$/, { timeout: 30_000 });
}
function createFinancialsPayload(input: {
ticker: string;
companyName: string;
cadence: 'annual' | 'quarterly' | 'ltm';
surface: string;
}) {
return {
financials: {
company: {
ticker: input.ticker,
companyName: input.companyName,
cik: null
},
surfaceKind: input.surface,
cadence: input.cadence,
displayModes: ['standardized'],
defaultDisplayMode: 'standardized',
periods: [
{
id: `${input.ticker}-p1`,
filingId: 1,
accessionNumber: `0000-${input.ticker}-1`,
filingDate: '2025-02-01',
periodStart: '2024-01-01',
periodEnd: '2024-12-31',
filingType: '10-K',
periodLabel: 'FY 2024'
},
{
id: `${input.ticker}-p2`,
filingId: 2,
accessionNumber: `0000-${input.ticker}-2`,
filingDate: '2026-02-01',
periodStart: '2025-01-01',
periodEnd: '2025-12-31',
filingType: '10-K',
periodLabel: 'FY 2025'
}
],
statementRows: {
faithful: [],
standardized: [
{
key: 'revenue',
label: 'Revenue',
category: 'revenue',
order: 10,
unit: 'currency',
values: {
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 320 : 280,
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 360 : 330
},
sourceConcepts: ['revenue'],
sourceRowKeys: ['revenue'],
sourceFactIds: [1],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: {
[`${input.ticker}-p1`]: 'revenue',
[`${input.ticker}-p2`]: 'revenue'
}
},
{
key: 'total_assets',
label: 'Total Assets',
category: 'asset',
order: 20,
unit: 'currency',
values: {
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 410 : 380,
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 450 : 420
},
sourceConcepts: ['total_assets'],
sourceRowKeys: ['total_assets'],
sourceFactIds: [2],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: {
[`${input.ticker}-p1`]: 'total_assets',
[`${input.ticker}-p2`]: 'total_assets'
}
},
{
key: 'free_cash_flow',
label: 'Free Cash Flow',
category: 'cash_flow',
order: 30,
unit: 'currency',
values: {
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 95 : 80,
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 105 : 92
},
sourceConcepts: ['free_cash_flow'],
sourceRowKeys: ['free_cash_flow'],
sourceFactIds: [3],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: {
[`${input.ticker}-p1`]: 'free_cash_flow',
[`${input.ticker}-p2`]: 'free_cash_flow'
}
}
]
},
ratioRows: [
{
key: 'gross_margin',
label: 'Gross Margin',
category: 'margins',
order: 10,
unit: 'percent',
values: {
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 0.43 : 0.39,
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 0.45 : 0.41
},
sourceConcepts: ['gross_margin'],
sourceRowKeys: ['gross_margin'],
sourceFactIds: [4],
formulaKey: 'gross_margin',
hasDimensions: false,
resolvedSourceRowKeys: {
[`${input.ticker}-p1`]: null,
[`${input.ticker}-p2`]: null
},
denominatorKey: 'revenue'
}
],
kpiRows: null,
trendSeries: [],
categories: [],
availability: {
adjusted: false,
customMetrics: false
},
nextCursor: null,
facts: null,
coverage: {
filings: 2,
rows: 3,
dimensions: 0,
facts: 0
},
dataSourceStatus: {
enabled: true,
hydratedFilings: 2,
partialFilings: 0,
failedFilings: 0,
pendingFilings: 0,
queuedSync: false
},
metrics: {
taxonomy: null,
validation: null
},
dimensionBreakdown: null
}
};
}
async function mockGraphingFinancials(page: Page) {
await page.route('**/api/financials/company**', async (route) => {
const url = new URL(route.request().url());
const ticker = url.searchParams.get('ticker') ?? 'MSFT';
const cadence = (url.searchParams.get('cadence') ?? 'annual') as 'annual' | 'quarterly' | 'ltm';
const surface = url.searchParams.get('surface') ?? 'income_statement';
if (ticker === 'BAD') {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Ticker not found' })
});
return;
}
const companyName = ticker === 'AAPL'
? 'Apple Inc.'
: ticker === 'NVDA'
? 'NVIDIA Corporation'
: ticker === 'AMD'
? 'Advanced Micro Devices, Inc.'
: 'Microsoft Corporation';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(createFinancialsPayload({
ticker,
companyName,
cadence,
surface
}))
});
});
}
test('supports graphing compare controls and partial failures', async ({ page }, testInfo) => {
await signUp(page, testInfo);
await mockGraphingFinancials(page);
await page.goto('/graphing');
await expect(page).toHaveURL(/tickers=MSFT%2CAAPL%2CNVDA/);
await expect(page.getByRole('heading', { name: 'Graphing' })).toBeVisible();
await expect(page.getByText('Microsoft Corporation')).toBeVisible();
await page.getByRole('button', { name: 'Graph surface Balance Sheet' }).click();
await expect(page).toHaveURL(/surface=balance_sheet/);
await expect(page).toHaveURL(/metric=total_assets/);
await page.getByRole('button', { name: 'Graph cadence Quarterly' }).click();
await expect(page).toHaveURL(/cadence=quarterly/);
await page.getByRole('button', { name: 'Chart type Bar' }).click();
await expect(page).toHaveURL(/chart=bar/);
await page.getByRole('button', { name: 'Remove AAPL' }).click();
await expect(page).not.toHaveURL(/AAPL/);
await page.getByLabel('Compare tickers').fill('MSFT, NVDA, AMD');
await page.getByRole('button', { name: 'Update Compare Set' }).click();
await expect(page).toHaveURL(/tickers=MSFT%2CNVDA%2CAMD/);
await expect(page.getByText('Advanced Micro Devices, Inc.')).toBeVisible();
await page.goto('/graphing?tickers=MSFT,BAD&surface=income_statement&metric=revenue&cadence=annual&chart=line&scale=millions');
await expect(page.getByText('Partial coverage detected.')).toBeVisible();
await expect(page.getByRole('cell', { name: /BAD/ })).toBeVisible();
await expect(page.getByText('Ticker not found')).toBeVisible();
await expect(page.getByText('Microsoft Corporation')).toBeVisible();
});

View File

@@ -17,14 +17,59 @@ function toSlug(value: string) {
async function signUp(page: Page, testInfo: TestInfo) {
const email = `playwright-research-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`;
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3400';
const output = execFileSync('bun', [
'-e',
`
const [email, password] = process.argv.slice(1);
const { auth } = await import('./lib/auth');
const response = await auth.api.signUpEmail({
body: {
name: 'Playwright Research User',
email,
password,
callbackURL: '/'
},
asResponse: true
});
await page.goto('/auth/signup');
await page.locator('input[autocomplete="name"]').fill('Playwright Research User');
await page.locator('input[autocomplete="email"]').fill(email);
await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD);
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
console.log(JSON.stringify({
status: response.status,
sessionCookie: response.headers.get('set-cookie')
}));
`,
email,
PASSWORD
], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: `file:${E2E_DATABASE_PATH}`,
BETTER_AUTH_BASE_URL: baseURL,
BETTER_AUTH_SECRET: 'playwright-e2e-secret-playwright-e2e-secret',
BETTER_AUTH_TRUSTED_ORIGINS: baseURL
},
encoding: 'utf8'
});
const { status, sessionCookie } = JSON.parse(output) as { status: number; sessionCookie: string | null };
expect(status).toBe(200);
expect(sessionCookie).toBeTruthy();
const [cookieNameValue] = sessionCookie!.split(';');
const separatorIndex = cookieNameValue!.indexOf('=');
const cookieName = cookieNameValue!.slice(0, separatorIndex);
const cookieValue = cookieNameValue!.slice(separatorIndex + 1);
await page.context().addCookies([{
name: cookieName,
value: cookieValue,
url: baseURL,
httpOnly: true,
sameSite: 'Lax'
}]);
await page.goto('/');
await expect(page).toHaveURL(/\/$/);
return email;
}
@@ -109,7 +154,9 @@ finally:
}
test('supports the core coverage-to-research workflow', async ({ page }, testInfo) => {
test.slow();
const accessionNumber = `0001045810-26-${String(Date.now()).slice(-6)}`;
const uploadFixture = join(process.cwd(), 'e2e', 'fixtures', 'sample-research.txt');
await signUp(page, testInfo);
seedFiling({
@@ -139,30 +186,61 @@ test('supports the core coverage-to-research workflow', async ({ page }, testInf
await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/);
await expect(page.getByText('Coverage Workflow')).toBeVisible();
await page.getByLabel('Journal title').fill('Own-the-stack moat check');
await page.getByLabel('Journal body').fill('Monitor hyperscaler concentration, gross margin durability, and Blackwell shipment cadence.');
await page.getByRole('link', { name: 'Open research' }).click();
await expect(page).toHaveURL(/\/research\?ticker=NVDA/);
await page.getByLabel('Research note title').fill('Own-the-stack moat check');
await page.getByLabel('Research note summary').fill('Initial moat checkpoint');
await page.getByLabel('Research note body').fill('Monitor hyperscaler concentration, gross margin durability, and Blackwell shipment cadence.');
await page.getByLabel('Research note tags').fill('moat, thesis');
await page.getByRole('button', { name: 'Save note' }).click();
await expect(page.getByText('Own-the-stack moat check')).toBeVisible();
await expect(page.getByText('Saved note to the research library.')).toBeVisible();
await page.getByLabel('Upload title').fill('Supply-chain diligence');
await page.getByLabel('Upload summary').fill('Vendor and channel-check notes');
await page.getByLabel('Upload tags').fill('diligence, channel-check');
await page.getByLabel('Upload file').setInputFiles(uploadFixture);
await page.locator('button', { hasText: 'Upload file' }).click();
await expect(page.getByText('Uploaded research file.')).toBeVisible();
await page.goto(`/analysis?ticker=NVDA`);
await page.getByRole('link', { name: 'Open summary' }).first().click();
await expect(page).toHaveURL(/\/analysis\/reports\/NVDA\//);
await page.getByRole('button', { name: 'Add to journal' }).click();
await expect(page.getByText('Saved to the company research journal.')).toBeVisible();
await page.getByRole('button', { name: 'Save to library' }).click();
await expect(page.getByText('Saved to the company research library.')).toBeVisible();
await page.getByRole('link', { name: 'Back to analysis' }).click();
await page.goto('/research?ticker=NVDA');
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/research\?ticker=NVDA/);
await expect(page.getByRole('heading', { name: '10-K AI memo' }).first()).toBeVisible();
await page.getByLabel('Memo rating').selectOption('buy');
await page.getByLabel('Memo conviction').selectOption('high');
await page.getByLabel('Memo time horizon').fill('24');
await page.getByLabel('Packet title').fill('NVIDIA buy-side packet');
await page.getByLabel('Packet subtitle').fill('AI infrastructure compounder');
await page.getByLabel('Memo Thesis').fill('Maintain a constructive stance as datacenter demand and platform depth widen the moat.');
await page.getByLabel('Memo Catalysts').fill('Blackwell ramp, enterprise inference demand, and sustained operating leverage.');
await page.getByLabel('Memo Risks').fill('Customer concentration, competition, and execution on supply.');
await page.getByRole('button', { name: 'Save memo' }).click();
await expect(page.getByText('Saved investment memo.')).toBeVisible();
await page.getByRole('button', { name: 'Attach' }).first().click();
await expect(page.getByText('Attached evidence to Thesis.')).toBeVisible();
await page.goto('/analysis?ticker=NVDA');
await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/);
await expect(page.getByText('10-K AI memo')).toBeVisible();
await page.getByRole('link', { name: 'Open financials' }).click();
await page.goto('/financials?ticker=NVDA');
await expect(page).toHaveURL(/\/financials\?ticker=NVDA/);
await page.getByRole('link', { name: 'Filings' }).first().click();
await page.goto('/filings?ticker=NVDA');
await expect(page).toHaveURL(/\/filings\?ticker=NVDA/);
await expect(page.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible();
await expect(page.getByRole('button', { name: /journal/i }).first()).toBeVisible();
await expect(page.getByRole('link', { name: 'Summary' }).first()).toBeVisible();
});
test('supports add, edit, and delete holding flows with summary refresh', async ({ page }, testInfo) => {
test.slow();
await signUp(page, testInfo);
await page.goto('/portfolio');