Repository navigation
test: Playwright end-to-end test of the Inbox flow on docker compose #91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
JOY (JOY)
wants to merge
3
commits into
main
Choose a base branch
from
ccr-2d7e26ff-ad5z1y
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,8 @@ | |
| .git | ||
| .claude | ||
| .playwright-mcp | ||
| e2e/test-results | ||
| e2e/playwright-report | ||
| .data | ||
| .env | ||
| .env.* | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| name: e2e | ||
|
|
||
| on: | ||
| pull_request: | ||
| push: | ||
| branches: [main, dev] | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: e2e-${{ github.ref }} | ||
| # A newer push replaces a pull request's run; a push to main or dev always finishes. | ||
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | ||
|
|
||
| jobs: | ||
| inbox: | ||
| name: Playwright on docker compose | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 25 | ||
| steps: | ||
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 | ||
| with: | ||
| node-version: '26' | ||
|
|
||
| - name: Install pnpm | ||
| run: npm install -g pnpm@10.34.7 --no-fund --no-audit | ||
|
|
||
| - name: Install dependencies | ||
| run: pnpm install --frozen-lockfile --filter "@crove/e2e" | ||
|
|
||
| - name: Install Chromium | ||
| run: pnpm --filter @crove/e2e exec playwright install --with-deps chromium | ||
|
|
||
| # Fresh volumes on every run: the runner starts empty. | ||
| - name: Build and start the stack | ||
| run: pnpm --filter @crove/e2e stack:up | ||
|
|
||
| - name: Playwright | ||
| run: pnpm test:e2e | ||
|
|
||
| - name: Stack logs | ||
| if: failure() | ||
| run: pnpm --filter @crove/e2e stack:logs | ||
|
|
||
| - name: Stop the stack | ||
| if: always() | ||
| run: pnpm --filter @crove/e2e stack:down |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| // Waits until the stack is ready: every migration applied (/readyz) and Supabase Auth answering through the proxy. | ||
| import type { FullConfig } from '@playwright/test'; | ||
|
|
||
| const TIMEOUT_MS = 180_000; | ||
|
|
||
| async function ok(url: string): Promise<boolean> { | ||
| const response = await fetch(url, { signal: AbortSignal.timeout(5_000) }).catch(() => null); | ||
| return response?.ok ?? false; | ||
| } | ||
|
|
||
| export default async function globalSetup(config: FullConfig): Promise<void> { | ||
| const baseURL = config.projects[0]?.use.baseURL ?? 'http://localhost:8080'; | ||
| const deadline = Date.now() + TIMEOUT_MS; | ||
| while (Date.now() < deadline) { | ||
| if ((await ok(`${baseURL}/readyz`)) && (await ok(`${baseURL}/auth/v1/health`))) return; | ||
| await new Promise((resolve) => setTimeout(resolve, 2_000)); | ||
| } | ||
| throw new Error(`Crove at ${baseURL} is not ready after ${TIMEOUT_MS / 1000} s; start it with: pnpm --filter @crove/e2e stack:up`); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| // Reads the stack's database as the PostgreSQL superuser (no RLS), so assertions see exactly what was stored. | ||
| // Runs psql inside the compose `db` service; the database port is never published. | ||
| import { execFileSync } from 'node:child_process'; | ||
| import { fileURLToPath } from 'node:url'; | ||
|
|
||
| const COMPOSE_FILE = fileURLToPath(new URL('../../docker-compose.yml', import.meta.url)); | ||
| const PROJECT = process.env.E2E_COMPOSE_PROJECT ?? 'crove-e2e'; | ||
|
|
||
| // `query` uses psql variables (:'name') for every value in `vars`. Returns the rows as JSON objects. | ||
| export function sql<T>(query: string, vars: Record<string, string> = {}): T[] { | ||
| const args = ['compose', '-p', PROJECT, '-f', COMPOSE_FILE, 'exec', '-T', 'db', 'psql', '-U', 'postgres', '-d', 'postgres', '-X', '-q', '-t', '-A']; | ||
| args.push('-v', 'ON_ERROR_STOP=1'); | ||
| for (const [name, value] of Object.entries(vars)) args.push('-v', `${name}=${value}`); | ||
| const script = `SELECT coalesce(json_agg(row_to_json(q)), '[]'::json) FROM (${query}) q;\n`; | ||
| const out = execFileSync('docker', args, { input: script, encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit'] }); | ||
| return JSON.parse(out.trim()) as T[]; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| { | ||
| "name": "@crove/e2e", | ||
| "version": "0.1.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "license": "AGPL-3.0-only", | ||
| "description": "Playwright end-to-end tests against the docker compose stack (MVP exit criterion 4)", | ||
| "scripts": { | ||
| "stack:up": "docker compose -p crove-e2e -f ../docker-compose.yml up -d --build", | ||
| "stack:logs": "docker compose -p crove-e2e -f ../docker-compose.yml logs --no-color", | ||
| "stack:down": "docker compose -p crove-e2e -f ../docker-compose.yml down -v", | ||
| "test": "playwright test", | ||
| "typecheck": "tsc --noEmit -p tsconfig.json" | ||
| }, | ||
| "devDependencies": { | ||
| "@playwright/test": "catalog:", | ||
| "decimal.js": "catalog:", | ||
| "exceljs": "catalog:" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| // Runs against a stack started with `pnpm --filter @crove/e2e stack:up` (docker compose, project crove-e2e). | ||
| import { defineConfig, devices } from '@playwright/test'; | ||
|
|
||
| export default defineConfig({ | ||
| testDir: './tests', | ||
| globalSetup: './global-setup.ts', | ||
| // One stack, one flow: tests share the database, so they run one at a time. | ||
| workers: 1, | ||
| forbidOnly: Boolean(process.env.CI), | ||
| // A flaky end-to-end test is a bug to fix, not to retry. | ||
| retries: 0, | ||
| timeout: 180_000, | ||
| expect: { timeout: 15_000 }, | ||
| reporter: process.env.CI ? [['list'], ['github']] : 'list', | ||
| use: { | ||
| baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:8080', | ||
| // The SPA takes its language from the browser (#47); the tests read the Vietnamese screens. | ||
| locale: 'vi-VN', | ||
| trace: 'retain-on-failure', | ||
| screenshot: 'only-on-failure', | ||
| }, | ||
| projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| // MVP exit criterion 1, end to end: sign up -> create org -> upload an e-invoice -> proposal in the Inbox -> | ||
| // approve -> vendor, document and a balanced entry exist, with audit rows -> lists and the Excel export. | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { expect, test } from '@playwright/test'; | ||
| import { Decimal } from 'decimal.js'; | ||
| import ExcelJS from 'exceljs'; | ||
| import { sql } from '../lib/db.ts'; | ||
|
|
||
| const INVOICE = fileURLToPath(new URL('../../apps/extract/test/fixtures/einvoice-basic.xml', import.meta.url)); | ||
| const SELLER = 'CÔNG TY TNHH DỊCH VỤ MẪU'; | ||
| // The fixture's synthetic tax code: checksum-invalid on purpose (#157). | ||
| const SELLER_TAX_CODE = '0101234564'; | ||
| const MEMO = `HĐ C26TAB-1234 ${SELLER}`; | ||
|
|
||
| const eq = (value: unknown, expected: string) => new Decimal(String(value)).equals(expected); | ||
|
JOY marked this conversation as resolved.
|
||
|
|
||
| test('sign up, upload an e-invoice, approve it, then find it in the lists, the export and the database', async ({ page }) => { | ||
| const email = `e2e-${Date.now()}@example.com`; | ||
|
|
||
| await test.step('sign up', async () => { | ||
| await page.goto('/'); | ||
| await expect(page).toHaveURL(/\/login$/); | ||
| await page.getByRole('button', { name: 'Tạo tài khoản' }).click(); | ||
| await page.getByLabel('Email').fill(email); | ||
| await page.getByLabel('Mật khẩu').fill('e2e-password-0123'); | ||
| await page.getByRole('button', { name: 'Tạo tài khoản' }).click(); | ||
| await expect(page).toHaveURL(/\/onboarding$/); | ||
| }); | ||
|
|
||
| const slug = await test.step('create the first org', async () => { | ||
| await page.getByLabel('Tên công ty hoặc hộ kinh doanh').fill('Công ty E2E'); | ||
| await page.getByRole('button', { name: 'Tạo tổ chức' }).click(); | ||
| await expect(page).toHaveURL(/\/o\/[^/]+\/inbox$/); | ||
| await expect(page.getByRole('heading', { name: 'Hộp thư chứng từ' })).toBeVisible(); | ||
| return new URL(page.url()).pathname.split('/')[2] ?? ''; | ||
| }); | ||
|
|
||
| await test.step('upload the e-invoice and wait for the proposal', async () => { | ||
| await page.locator('input[type=file]').setInputFiles(INVOICE); | ||
| await expect(page.getByText('einvoice-basic.xml')).toBeVisible(); | ||
| // The worker reads the XML and raises the proposal; the Inbox polls every few seconds. | ||
| await expect(page.getByRole('link', { name: SELLER })).toBeVisible({ timeout: 60_000 }); | ||
| await expect(page.getByText('Đã có đề xuất')).toBeVisible(); | ||
| }); | ||
|
|
||
| await test.step('open the proposal and approve it', async () => { | ||
| await page.getByRole('link', { name: SELLER }).click(); | ||
| await expect(page.getByRole('heading', { name: SELLER })).toBeVisible(); | ||
| await expect(page.getByText('Hóa đơn C26TAB · số 1234 · ngày 15/09/2026')).toBeVisible(); | ||
| const approve = page.getByRole('button', { name: 'Duyệt và ghi sổ' }); | ||
| await expect(approve).toBeEnabled(); | ||
| await approve.click(); | ||
| await expect(page).toHaveURL(new RegExp(`/o/${slug}/inbox$`)); | ||
| await expect(page.getByText('Không có đề xuất nào chờ duyệt')).toBeVisible(); | ||
| await page.getByRole('tab', { name: 'Đã ghi sổ' }).click(); | ||
| await expect(page.getByRole('link', { name: SELLER })).toBeVisible(); | ||
| }); | ||
|
|
||
| await test.step('vendor, document and entry lists', async () => { | ||
| const nav = page.getByRole('navigation'); | ||
|
|
||
| await nav.getByRole('link', { name: 'Nhà cung cấp' }).click(); | ||
| const vendor = page.getByRole('row').filter({ hasText: SELLER }); | ||
| await expect(vendor).toHaveCount(1); | ||
| await expect(vendor.getByRole('cell')).toHaveText([SELLER, SELLER_TAX_CODE, /Hà Nội/, '1']); | ||
|
|
||
| await nav.getByRole('link', { name: 'Chứng từ mua' }).click(); | ||
| const document = page.getByRole('row').filter({ hasText: 'C26TAB-1234' }); | ||
| await expect(document).toHaveCount(1); | ||
| await expect(document.getByRole('cell')).toHaveText(['15/09/2026', 'C26TAB-1234', SELLER, /^1\.800\.000/, /^180\.000/, /^1\.980\.000/, 'Đã ghi sổ']); | ||
|
|
||
| await nav.getByRole('link', { name: 'Bút toán' }).click(); | ||
| await expect(page.getByText(MEMO)).toBeVisible(); | ||
| const line = (account: string) => page.getByRole('row').filter({ has: page.getByRole('cell', { name: new RegExp(`^${account} `) }) }); | ||
| await expect(line('1331').getByRole('cell')).toHaveText([/^1331 /, /^180\.000/, '']); | ||
| await expect(line('331').getByRole('cell')).toHaveText([/^331 /, '', /^1\.980\.000/]); | ||
| }); | ||
|
|
||
| await test.step('Excel export', async () => { | ||
| const download = page.waitForEvent('download'); | ||
| await page.getByRole('button', { name: 'Xuất Excel' }).click(); | ||
| const file = await download; | ||
| expect(file.suggestedFilename()).toBe(`crove-but-toan-${slug}.xlsx`); | ||
|
|
||
| const workbook = new ExcelJS.Workbook(); | ||
| await workbook.xlsx.readFile(await file.path()); | ||
| const sheet = workbook.worksheets[0]; | ||
| if (!sheet) throw new Error('the export has no worksheet'); | ||
| const header = (sheet.getRow(1).values as unknown[]).slice(1).map(String); | ||
|
JOY marked this conversation as resolved.
|
||
| const col = (name: string) => header.indexOf(name) + 1; | ||
| expect(header).toEqual(expect.arrayContaining(['Số HĐ', 'MST nhà cung cấp', 'TK Nợ', 'TK Có', 'Số tiền'])); | ||
|
|
||
| const rows = sheet.getRows(2, sheet.rowCount - 1) ?? []; | ||
| expect(rows.length).toBeGreaterThan(0); | ||
| let total = new Decimal(0); | ||
| for (const row of rows) { | ||
| expect(String(row.getCell(col('Số HĐ')).value)).toBe('1234'); | ||
| expect(String(row.getCell(col('MST nhà cung cấp')).value)).toBe(SELLER_TAX_CODE); | ||
| expect(String(row.getCell(col('TK Có')).value)).toBe('331'); | ||
| total = total.plus(String(row.getCell(col('Số tiền')).value)); | ||
| } | ||
| expect(total.toFixed(0)).toBe('1980000'); | ||
| }); | ||
|
|
||
| await test.step('rows in the database', async () => { | ||
| const [org] = sql<{ org_id: string; user_id: string }>( | ||
| `SELECT t.org_id, u.id AS user_id | ||
| FROM crove_kernel.tenant t JOIN crove_kernel.user_account u ON u.id = t.created_by | ||
| WHERE t.slug = :'slug' AND lower(u.email) = lower(:'email')`, | ||
| { slug, email }, | ||
| ); | ||
| if (!org) throw new Error(`org ${slug} created by ${email} not found`); | ||
| const vars = { org: org.org_id, user: org.user_id }; | ||
|
|
||
| const vendors = sql<{ id: string; name: string; tax_code: string }>( | ||
| `SELECT id, name, tax_code FROM crove_kernel.party WHERE org_id = :'org'`, | ||
| vars, | ||
| ); | ||
| expect(vendors).toEqual([{ id: expect.any(String), name: SELLER, tax_code: SELLER_TAX_CODE }]); | ||
|
|
||
| const documents = sql<{ id: string; party_id: string; invoice_series: string; invoice_number: string; subtotal: string; vat_amount: string; total: string; created_by: string }>( | ||
| `SELECT id, party_id, invoice_series, invoice_number, subtotal::text, vat_amount::text, total::text, created_by | ||
| FROM crove_finance.purchase_document WHERE org_id = :'org'`, | ||
| vars, | ||
| ); | ||
| expect(documents).toHaveLength(1); | ||
| const doc = documents[0]; | ||
| if (!doc) throw new Error('no purchase document'); | ||
| expect(doc).toMatchObject({ party_id: vendors[0]?.id, invoice_series: 'C26TAB', invoice_number: '1234', created_by: org.user_id }); | ||
| expect(eq(doc.subtotal, '1800000') && eq(doc.vat_amount, '180000') && eq(doc.total, '1980000')).toBe(true); | ||
|
|
||
| const entries = sql<{ source_type: string; source_id: string; debit: string; credit: string; lines: number }>( | ||
| `SELECT e.source_type, e.source_id, sum(l.debit)::text AS debit, sum(l.credit)::text AS credit, count(*)::int AS lines | ||
| FROM crove_finance.journal_entry e | ||
| JOIN crove_finance.journal_line l ON l.org_id = e.org_id AND l.entry_id = e.id | ||
| WHERE e.org_id = :'org' | ||
| GROUP BY e.org_id, e.id`, | ||
| vars, | ||
| ); | ||
| expect(entries).toHaveLength(1); | ||
| const entry = entries[0]; | ||
| if (!entry) throw new Error('no journal entry'); | ||
| expect(entry).toMatchObject({ source_type: 'finance.purchase_document', source_id: doc.id }); | ||
| expect(entry.lines).toBeGreaterThanOrEqual(2); | ||
| expect(eq(entry.debit, '1980000') && eq(entry.credit, '1980000')).toBe(true); | ||
|
|
||
| const audit = sql<{ command: string; outcome: string; actor_type: string; by_user: boolean }>( | ||
| `SELECT command, outcome, actor_type, coalesce(actor_id = :'user'::uuid, false) AS by_user | ||
| FROM crove_kernel.audit_event WHERE org_id = :'org' ORDER BY occurred_at, id`, | ||
| vars, | ||
| ); | ||
| expect(audit).toEqual( | ||
| expect.arrayContaining([ | ||
| { command: 'kernel.file.ingest', outcome: 'ok', actor_type: 'user', by_user: true }, | ||
| { command: 'kernel.extraction.record', outcome: 'ok', actor_type: 'system', by_user: false }, | ||
| { command: 'kernel.proposal.approve', outcome: 'ok', actor_type: 'user', by_user: true }, | ||
| { command: 'finance.purchase.record', outcome: 'ok', actor_type: 'user', by_user: true }, | ||
| ]), | ||
| ); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "extends": "../tsconfig.lib.json", | ||
| "compilerOptions": { "lib": ["ES2024", "DOM"] }, | ||
| "include": ["*.ts", "lib", "tests"] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.