Skip to content
Open
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 .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
.git
.claude
.playwright-mcp
e2e/test-results
e2e/playwright-report
.data
.env
.env.*
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:

# Every module, also one the kernel does not import yet: typecheck and unit tests run over all of them.
- name: Install dependencies
run: pnpm install --frozen-lockfile --filter "@crove/kernel-server..." --filter "@crove/app..." --filter "./modules/*" --filter "crove-monorepo"
run: pnpm install --frozen-lockfile --filter "@crove/kernel-server..." --filter "@crove/app..." --filter "./modules/*" --filter "@crove/e2e" --filter "crove-monorepo"

- name: Lint
run: pnpm lint
Expand Down
52 changes: 52 additions & 0 deletions .github/workflows/e2e.yml
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,7 @@ next-env.d.ts
# Local artifacts
.data
.playwright-mcp

# Playwright output
e2e/test-results
e2e/playwright-report
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Crove is the open-source Business OS (AGPL). It must run with `docker compose up
- `pnpm db:migrate` applies `supabase/migrations` with the built-in runner
- `pnpm db:types` (needs `CROVE_TEST_ADMIN_URL`) regenerates `packages/db/src/generated/database.ts`, the Kysely types of every table `crove_tenant` may read, with Kanel from a throwaway database the migrations build. Run it after a migration that changes such a table or its grants and commit the file; CI fails when it is out of date. Insert and update types follow `crove_tenant`'s column privileges: a column the role may not write has the write type `NotGranted`, so a forbidden write (an insert into `outbox_event` or the ledger, an update of an append-only table) fails typecheck (through `values()` and `set()`; deletes, raw SQL and insert ... select are refused only at runtime); a refinement cannot lift that. Refine a column (a CHECK-constraint union, a trigger-set column) in `packages/db/src/schema.ts`, never in the generated file. A CHECK-constraint union is a const array there (`TASK_STATUSES` and its type `TaskStatus`); `packages/db/test/check-unions.db.test.ts` compares every fixed-set CHECK constraint with its array, so a migration that adds, widens or narrows one fails until the array follows and is listed in that test.
- `docker compose up --build` runs the full stack on http://localhost:8080
- `pnpm test:e2e` runs Playwright against a stack started with `pnpm --filter @crove/e2e stack:up` (compose project `crove-e2e`, port 8080; `stack:down` removes it with its volumes). Install the browser once with `pnpm --filter @crove/e2e exec playwright install chromium`

## Updating pinned images

Expand Down
12 changes: 6 additions & 6 deletions docs/plan/crove-os-kernel.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docs/plan/crove-os-kernel.vi.html

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions e2e/global-setup.ts
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';
Comment thread
JOY marked this conversation as resolved.
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`);
}
17 changes: 17 additions & 0 deletions e2e/lib/db.ts
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[];
}
20 changes: 20 additions & 0 deletions e2e/package.json
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:"
}
}
23 changes: 23 additions & 0 deletions e2e/playwright.config.ts
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'] } }],
});
161 changes: 161 additions & 0 deletions e2e/tests/inbox.spec.ts
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);
Comment thread
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);
Comment thread
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 },
]),
);
});
});
5 changes: 5 additions & 0 deletions e2e/tsconfig.json
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"]
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
"dev:web": "pnpm --filter @crove/web dev",
"build": "pnpm -r --filter=!@crove/web build",
"typecheck": "pnpm -r --filter=!@crove/web typecheck && tsc --noEmit -p tools/tsconfig.json",
"lint": "oxlint --deny-warnings apps/kernel apps/extract apps/app packages modules && node tools/ui-colors.mjs",
"lint": "oxlint --deny-warnings apps/kernel apps/extract apps/app packages modules e2e && node tools/ui-colors.mjs",
"test": "vitest run --project unit",
"test:db": "vitest run --project db",
"test:e2e": "pnpm --filter @crove/e2e test",
"ai:conformance": "vitest run --project ai conformance",
"ai:eval": "vitest run --project ai extraction-eval --silent=false",
"docs:check": "node tools/docs-check.mjs",
Expand Down
Loading
Loading