diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml new file mode 100644 index 0000000..090a470 --- /dev/null +++ b/.github/workflows/ui-tests.yml @@ -0,0 +1,165 @@ +name: UI Tests + +on: + push: + branches: [develop] + pull_request: + workflow_dispatch: + +concurrency: + group: ui-tests-ledger_lab-${{ github.event.number || github.ref }} + cancel-in-progress: true + +jobs: + ui-tests: + runs-on: ubuntu-latest + timeout-minutes: 60 + name: Playwright E2E Tests + + services: + redis-cache: + image: redis:alpine + ports: + - 13000:6379 + redis-queue: + image: redis:alpine + ports: + - 11000:6379 + mariadb: + image: mariadb:11.8 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + steps: + - name: Clone + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + check-latest: true + + - name: Add to Hosts + run: echo "127.0.0.1 ledger.test" | sudo tee -a /etc/hosts + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }} + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT + + - name: Cache yarn + uses: actions/cache@v4 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/package.json') }} + + - name: Install MariaDB Client + run: | + sudo apt update + sudo apt-get install mariadb-client + + - name: Setup Bench + run: | + pip install frappe-bench + bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + + - name: Install App + working-directory: /home/runner/frappe-bench + run: | + # Ledger Lab is an ERPNext app — pull ERPNext first, then this app. + # Default branch (develop) matches the dev environment this app targets. + bench get-app erpnext + bench get-app ledger_lab $GITHUB_WORKSPACE + bench setup requirements --dev + bench new-site --db-root-password root --admin-password admin ledger.test + bench --site ledger.test install-app erpnext + bench --site ledger.test install-app ledger_lab + bench build + env: + CI: "Yes" + + - name: Configure Site + working-directory: /home/runner/frappe-bench + run: | + bench --site ledger.test set-config allow_tests true + bench --site ledger.test set-config host_name "http://ledger.test:8000" + bench --site ledger.test set-config mute_emails 1 + + - name: Seed Test Data + working-directory: /home/runner/frappe-bench + run: | + bench --site ledger.test execute frappe.utils.password.update_password --args "['Administrator', 'admin']" + bench --site ledger.test execute ledger_lab.e2e_seed.setup_e2e_data + + - name: Start Frappe Server + working-directory: /home/runner/frappe-bench + run: | + sed -i 's/^watch:/# watch:/g' Procfile + sed -i 's/^schedule:/# schedule:/g' Procfile + bench start &> bench_start.log & + echo "Waiting for Frappe server to start..." + timeout 60 bash -c 'until curl -s http://ledger.test:8000 > /dev/null; do sleep 2; done' + echo "Frappe server is ready!" + + - name: Install Playwright + run: | + cd $GITHUB_WORKSPACE + npm install + npx playwright install --with-deps chromium + + - name: Run Playwright Tests + working-directory: ${{ github.workspace }} + run: npx playwright test + env: + BASE_URL: http://ledger.test:8000 + SITE_HOST: ledger.test:8000 + FRAPPE_USER: Administrator + FRAPPE_PASSWORD: admin + + - name: Upload Playwright Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-results + path: test-results/ + retention-days: 7 + + - name: Show Bench Logs on Failure + if: failure() + working-directory: /home/runner/frappe-bench + run: | + echo "=== Bench Start Log ===" + cat bench_start.log || true + echo "" + echo "=== Frappe Logs ===" + cat logs/*.log || true diff --git a/.gitignore b/.gitignore index b50d332..0ddc55a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,12 @@ venv.bak/ node_modules/ jspm_packages/ +# Playwright E2E +/test-results/ +/playwright-report/ +/playwright/.cache/ +e2e/.auth/ + # IDEs and editors .vscode/ .vs/ diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..64c6758 --- /dev/null +++ b/e2e/helpers/auth.ts @@ -0,0 +1,63 @@ +import { APIRequestContext, Page } from "@playwright/test"; + +/** + * Login via Frappe API (faster than UI login). + * Sets cookies on the request context for subsequent API calls. + */ +export async function loginViaAPI( + request: APIRequestContext, + email = "Administrator", + password = "admin", +): Promise { + const response = await request.post("/api/method/login", { + form: { + usr: email, + pwd: password, + }, + }); + + if (!response.ok()) { + throw new Error(`Login failed: ${response.status()} ${await response.text()}`); + } +} + +/** + * Login via UI (for testing the login flow itself). + */ +export async function loginViaUI( + page: Page, + email = "Administrator", + password = "admin", +): Promise { + await page.goto("/login"); + await page.waitForLoadState("networkidle"); + + await page.fill('input[data-fieldname="email"]', email); + await page.fill('input[data-fieldname="password"]', password); + await page.click('button[type="submit"]'); + + await page.waitForURL(/\/(app|desk)/, { timeout: 30000 }); +} + +/** + * Logout the current user. + */ +export async function logout(page: Page): Promise { + await page.goto("/api/method/logout"); + await page.waitForLoadState("networkidle"); +} + +/** + * Check if user is logged in by verifying session. + */ +export async function isLoggedIn(request: APIRequestContext): Promise { + try { + const response = await request.get("/api/method/frappe.auth.get_logged_user"); + if (!response.ok()) return false; + + const data = await response.json(); + return data.message && data.message !== "Guest"; + } catch { + return false; + } +} diff --git a/e2e/helpers/frappe.ts b/e2e/helpers/frappe.ts new file mode 100644 index 0000000..630d486 --- /dev/null +++ b/e2e/helpers/frappe.ts @@ -0,0 +1,258 @@ +import * as fs from "fs"; +import { APIRequestContext } from "@playwright/test"; + +/** + * Frappe API response wrapper. + */ +export interface FrappeResponse { + message?: T; + exc?: string; + exc_type?: string; + _server_messages?: string; +} + +// Path to CSRF token file saved by auth.setup.ts +const CSRF_FILE = "e2e/.auth/csrf.json"; +// Path to auth storage state file saved by auth.setup.ts +const AUTH_FILE = "e2e/.auth/user.json"; + +// Node.js can't resolve .localhost TLDs, so API calls go via 127.0.0.1 + Host header. +const SITE_HOST = process.env.SITE_HOST || "ledger.localhost:8000"; +export const API_BASE = process.env.API_BASE || "http://127.0.0.1:8000"; + +// Cache for CSRF token (read from file once) +let csrfTokenCache: string | null = null; +// Cache for auth cookies (read from file once) +let cookieCache: string | null = null; + +/** + * Get CSRF token from the file saved during auth setup. + */ +function getCsrfToken(): string { + if (csrfTokenCache !== null) { + return csrfTokenCache; + } + + try { + if (fs.existsSync(CSRF_FILE)) { + const data = JSON.parse(fs.readFileSync(CSRF_FILE, "utf-8")); + csrfTokenCache = data.csrf_token || ""; + return csrfTokenCache; + } + } catch (error) { + console.warn("Failed to read CSRF token file:", error); + } + + csrfTokenCache = ""; + return ""; +} + +/** + * Get auth cookies from the storage state file saved during auth setup. + * Needed because the request fixture sends to 127.0.0.1 but cookies are + * scoped to the site domain, so they won't be sent automatically. + */ +function getAuthCookies(): string { + if (cookieCache !== null) { + return cookieCache; + } + + try { + if (fs.existsSync(AUTH_FILE)) { + const data = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8")); + const cookies = data.cookies as Array<{ name: string; value: string }>; + if (cookies?.length) { + cookieCache = cookies.map((c) => `${c.name}=${c.value}`).join("; "); + return cookieCache; + } + } + } catch (error) { + console.warn("Failed to read auth cookies file:", error); + } + + cookieCache = ""; + return ""; +} + +/** + * Build common headers for API requests (CSRF + Host + auth cookies). + */ +function apiHeaders(extra: Record = {}): Record { + const csrfToken = getCsrfToken(); + const cookies = getAuthCookies(); + return { + Host: SITE_HOST, + ...(csrfToken ? { "X-Frappe-CSRF-Token": csrfToken } : {}), + ...(cookies ? { Cookie: cookies } : {}), + ...extra, + }; +} + +/** + * Create a new document via Frappe REST API. + */ +export async function createDoc>( + request: APIRequestContext, + doctype: string, + doc: Record, +): Promise { + const response = await request.post(`${API_BASE}/api/resource/${doctype}`, { + data: doc, + headers: apiHeaders({ "Content-Type": "application/json" }), + }); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to create ${doctype}: ${error}`); + } + + const result = await response.json(); + return result.data as T; +} + +/** + * Get a document by name via Frappe REST API. + */ +export async function getDoc>( + request: APIRequestContext, + doctype: string, + name: string, +): Promise { + const response = await request.get( + `${API_BASE}/api/resource/${doctype}/${encodeURIComponent(name)}`, + { headers: apiHeaders() }, + ); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to get ${doctype}/${name}: ${error}`); + } + + const result = await response.json(); + return result.data as T; +} + +/** + * Update a document via Frappe REST API. + */ +export async function updateDoc>( + request: APIRequestContext, + doctype: string, + name: string, + updates: Record, +): Promise { + const response = await request.put( + `${API_BASE}/api/resource/${doctype}/${encodeURIComponent(name)}`, + { + data: updates, + headers: apiHeaders({ "Content-Type": "application/json" }), + }, + ); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to update ${doctype}/${name}: ${error}`); + } + + const result = await response.json(); + return result.data as T; +} + +/** + * Delete a document via Frappe REST API. + */ +export async function deleteDoc( + request: APIRequestContext, + doctype: string, + name: string, +): Promise { + const response = await request.delete( + `${API_BASE}/api/resource/${doctype}/${encodeURIComponent(name)}`, + { headers: apiHeaders() }, + ); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to delete ${doctype}/${name}: ${error}`); + } +} + +/** + * Call a Frappe whitelisted method. + */ +export async function callMethod( + request: APIRequestContext, + method: string, + args: Record = {}, +): Promise { + const response = await request.post(`${API_BASE}/api/method/${method}`, { + data: args, + headers: apiHeaders({ "Content-Type": "application/json" }), + }); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to call ${method}: ${error}`); + } + + const result: FrappeResponse = await response.json(); + return result.message as T; +} + +/** + * Get a list of documents via Frappe REST API. + */ +export async function getList>( + request: APIRequestContext, + doctype: string, + options: { + fields?: string[]; + filters?: Record; + limit?: number; + orderBy?: string; + } = {}, +): Promise { + const params = new URLSearchParams(); + + if (options.fields) { + params.set("fields", JSON.stringify(options.fields)); + } + if (options.filters) { + params.set("filters", JSON.stringify(options.filters)); + } + if (options.limit) { + params.set("limit_page_length", options.limit.toString()); + } + if (options.orderBy) { + params.set("order_by", options.orderBy); + } + + const response = await request.get( + `${API_BASE}/api/resource/${doctype}?${params.toString()}`, + { headers: apiHeaders() }, + ); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to get list of ${doctype}: ${error}`); + } + + const result = await response.json(); + return result.data as T[]; +} + +/** + * Check if a document exists. + */ +export async function docExists( + request: APIRequestContext, + doctype: string, + name: string, +): Promise { + try { + await getDoc(request, doctype, name); + return true; + } catch { + return false; + } +} diff --git a/e2e/helpers/index.ts b/e2e/helpers/index.ts new file mode 100644 index 0000000..d94023b --- /dev/null +++ b/e2e/helpers/index.ts @@ -0,0 +1,2 @@ +export * from "./auth"; +export * from "./frappe"; diff --git a/e2e/tests/auth.setup.ts b/e2e/tests/auth.setup.ts new file mode 100644 index 0000000..65c8abf --- /dev/null +++ b/e2e/tests/auth.setup.ts @@ -0,0 +1,86 @@ +import * as fs from "fs"; +import * as path from "path"; +import { expect, test as setup } from "@playwright/test"; + +const authFile = "e2e/.auth/user.json"; +const csrfFile = "e2e/.auth/csrf.json"; + +/** + * Authentication setup - runs once before all tests. + * Uses browser fetch() so cookies are saved for the correct domain + * (Chromium resolves ledger.localhost via --host-resolver-rules). + */ +setup("authenticate", async ({ page }) => { + const authDir = path.dirname(authFile); + if (!fs.existsSync(authDir)) { + fs.mkdirSync(authDir, { recursive: true }); + } + + const usr = process.env.FRAPPE_USER || "Administrator"; + const pwd = process.env.FRAPPE_PASSWORD || "admin"; + + // Navigate to login page first to establish the domain context. + await page.goto("/login"); + await page.waitForLoadState("domcontentloaded"); + + // Login via browser fetch (keeps cookies on the correct domain). + const loginResult = await page.evaluate( + async ({ usr, pwd }) => { + const resp = await fetch("/api/method/login", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `usr=${encodeURIComponent(usr)}&pwd=${encodeURIComponent(pwd)}`, + }); + return { ok: resp.ok, status: resp.status }; + }, + { usr, pwd }, + ); + expect(loginResult.ok).toBeTruthy(); + + // Verify login succeeded. + const loggedUser = await page.evaluate(async () => { + const resp = await fetch("/api/method/frappe.auth.get_logged_user"); + const data = await resp.json(); + return data.message as string; + }); + expect(loggedUser).toBeTruthy(); + expect(loggedUser).not.toBe("Guest"); + console.log(`Authenticated as: ${loggedUser}`); + + // The session cookies (set by the login fetch above) are what matter for + // authenticated tests, so save the storage state first and unconditionally. + await page.context().storageState({ path: authFile }); + + // CSRF token is best-effort: only needed for write requests via the API + // helpers. Navigating to the freshly-booted desk in CI can trigger a + // client-side redirect that destroys the execution context mid-evaluate, so + // retrieve it defensively and never let it fail the whole setup. + try { + await page.goto("/app", { waitUntil: "domcontentloaded" }); + await page + .waitForFunction( + () => + (window as unknown as { frappe?: { csrf_token?: string } }).frappe + ?.csrf_token !== undefined, + { timeout: 30000 }, + ) + .catch(() => {}); + + const csrfToken = await page + .evaluate( + () => + (window as unknown as { frappe?: { csrf_token?: string } }).frappe + ?.csrf_token, + ) + .catch(() => undefined); + + if (csrfToken) { + fs.writeFileSync(csrfFile, JSON.stringify({ csrf_token: csrfToken })); + console.log("CSRF token saved"); + } else { + console.warn("CSRF token not found, continuing without it"); + } + } catch { + console.warn("CSRF retrieval skipped (desk navigation race)"); + } +}); diff --git a/e2e/tests/dashboard.spec.ts b/e2e/tests/dashboard.spec.ts new file mode 100644 index 0000000..1c39a6f --- /dev/null +++ b/e2e/tests/dashboard.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from "@playwright/test"; + +/** + * Basic smoke test for the Ledger Lab desk page (/app/ledger-lab). + * + * Verifies that the dashboard renders its core structure and that the + * live balances load from the backend `get_balances` API — proven by the + * accounting-equation status pill resolving to "Balanced" (a trial-balance + * closure that only appears once real numbers replace the "—" placeholders). + */ +test.describe("Ledger Lab dashboard", () => { + test("renders the equation, the six boxes, and loads balanced figures", async ({ + page, + }) => { + await page.goto("/app/ledger-lab"); + + // The desk SPA loads the page controller (which builds .ll-wrap) after + // the route resolves — allow a generous timeout for a cold CI server. + const wrap = page.locator(".ll-wrap"); + await expect(wrap).toBeVisible({ timeout: 30000 }); + + // Hero: the accounting equation. + await expect(page.locator(".ll-eq-kicker")).toHaveText( + "The Accounting Equation", + ); + + // Five root-type boxes + the derived Net Profit box. + await expect(page.locator(".ll-box")).toHaveCount(6); + + // Balance Sheet and P&L sections both render. + await expect(page.locator(".ll-section.bs")).toBeVisible(); + await expect(page.locator(".ll-section.pl")).toBeVisible(); + await expect(page.locator(".ll-feed")).toBeVisible(); + + // Live data path: once get_balances resolves, the equation pill reports + // the books are balanced (Assets = Liabilities + Equity + Net Profit). + const status = page.locator("[data-eq-status]"); + await expect(status).toContainText("Balanced", { timeout: 15000 }); + + // And the box values are no longer the "—" placeholder. + const assetValue = page.locator('.ll-box[data-root="Asset"] .ll-num'); + await expect(assetValue).not.toHaveText("—"); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..e8ec3e0 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@helpers/*": ["helpers/*"] + } + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/ledger_lab/e2e_seed.py b/ledger_lab/e2e_seed.py new file mode 100644 index 0000000..8d41966 --- /dev/null +++ b/ledger_lab/e2e_seed.py @@ -0,0 +1,144 @@ +"""Seed script for E2E Playwright tests. + +A fresh CI site (bare `bench install-app erpnext`, no setup wizard) has no +company, fiscal year, or chart of accounts, so the Ledger Lab dashboard's +`get_balances` API would throw. Creating a Company directly also fails because +ERPNext's company hooks reference preset records (e.g. Warehouse Type "Transit") +that only the setup wizard installs. + +So we run ERPNext's programmatic setup-wizard entrypoint, which installs the +country presets + chart of accounts, creates the company + fiscal year, and sets +it as the default — then we post one submitted Journal Entry so the dashboard has +live figures and a feed entry to show. + +Run via: bench --site execute ledger_lab.e2e_seed.setup_e2e_data +""" + +import frappe +from frappe.utils import nowdate + +COMPANY_NAME = "Ledger Lab" +COMPANY_ABBR = "LL" +COUNTRY = "India" +CURRENCY = "INR" + + +def setup_e2e_data(): + """Run the ERPNext setup wizard (if needed) and post a seed Journal Entry.""" + company = _ensure_company() + _ensure_default_company(company) + _mark_setup_complete() + _post_seed_journal_entry(company) + frappe.db.commit() + print(f"E2E seed complete for company {company!r}") + + +def _mark_setup_complete() -> None: + """Mark every installed app's setup as complete. + + ERPNext's `setup_complete` stage-runner installs the company/fixtures but + does not flip the setup-complete flag, so the desk would otherwise redirect + every route (including /app/ledger-lab) to the onboarding wizard. The gate + `frappe.is_setup_complete()` reads `Installed Application.is_setup_complete` + for each app (frappe + erpnext), so set it on all of them. + """ + for app_name in frappe.get_all("Installed Application", pluck="app_name"): + frappe.db.set_value("Installed Application", {"app_name": app_name}, "is_setup_complete", 1) + frappe.clear_cache() + + +def _ensure_company() -> str: + """Create a fully-configured company via ERPNext's setup wizard. + + `setup_complete` is ERPNext's documented programmatic entrypoint: it installs + country fixtures (Warehouse Types, UOMs, …), the chart of accounts, the + company, a fiscal year, price lists, and global defaults in one call. + """ + existing = frappe.db.get_value("Company", {"company_name": COMPANY_NAME}, "name") + if existing: + return existing + + from erpnext.setup.setup_wizard.setup_wizard import setup_complete + + year = int(nowdate()[:4]) + frappe.flags.in_setup_wizard = True + try: + setup_complete( + frappe._dict( + { + "country": COUNTRY, + "company_name": COMPANY_NAME, + "company_abbr": COMPANY_ABBR, + "currency": CURRENCY, + "chart_of_accounts": "Standard", + "domain": "Distribution", + "fy_start_date": f"{year}-01-01", + "fy_end_date": f"{year}-12-31", + } + ) + ) + finally: + frappe.flags.in_setup_wizard = False + + # `make_records` swallows insert errors (rollback + log), so verify the + # company actually materialized rather than failing later with a vaguer error. + company = frappe.db.get_value("Company", {"company_name": COMPANY_NAME}, "name") + if not company: + frappe.throw(f"Setup wizard did not create company {COMPANY_NAME!r}") + return company + + +def _ensure_default_company(company: str) -> None: + defaults = frappe.get_single("Global Defaults") + if defaults.default_company != company: + defaults.default_company = company + defaults.save(ignore_permissions=True) + # Used by ERPNext + Ledger Lab's _resolve_company fallback. + frappe.db.set_default("company", company) + + +def _post_seed_journal_entry(company: str) -> None: + """Dr a cash/asset account, Cr an income account — a tiny balanced entry.""" + if frappe.db.exists("Journal Entry", {"company": company, "title": "E2E Seed"}): + return + + debit_account = _leaf_account(company, root_type="Asset") + credit_account = _leaf_account(company, root_type="Income") + # P&L (income) JE lines require a cost center; harmless on the asset line. + cost_center = frappe.db.get_value("Company", company, "cost_center") + + je = frappe.get_doc( + { + "doctype": "Journal Entry", + "voucher_type": "Journal Entry", + "title": "E2E Seed", + "company": company, + "posting_date": nowdate(), + "accounts": [ + { + "account": debit_account, + "debit_in_account_currency": 1000, + "cost_center": cost_center, + }, + { + "account": credit_account, + "credit_in_account_currency": 1000, + "cost_center": cost_center, + }, + ], + } + ) + je.insert(ignore_permissions=True) + je.submit() + + +def _leaf_account(company: str, root_type: str) -> str: + account = frappe.db.get_value( + "Account", + {"company": company, "root_type": root_type, "is_group": 0}, + "name", + order_by="lft", + ) + if not account: + frappe.throw(f"No leaf {root_type} account found for company {company}") + return account diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bdbc245 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,79 @@ +{ + "name": "ledger_lab", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ledger_lab", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@playwright/test": "^1.58.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bd8d37a --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "ledger_lab", + "version": "1.0.0", + "description": "Live ledger-impact teaching dashboard for ERPNext", + "private": true, + "type": "commonjs", + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/BuildWithHussain/ledger_lab.git" + }, + "license": "MIT", + "devDependencies": { + "@playwright/test": "^1.58.2" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e0cc911 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,56 @@ +import { defineConfig, devices } from "@playwright/test"; +import path from "path"; + +const authFile = path.join(__dirname, "e2e", ".auth", "user.json"); + +// Frappe multisite routing requires the Host header to match the site name. +// Node.js can't resolve .localhost TLDs, so we connect to 127.0.0.1 and set +// the Host header. Browser navigations use Chromium's --host-resolver-rules. +const SITE_HOST = process.env.SITE_HOST || "ledger.localhost:8000"; + +// Bare hostname (no port) for the Chromium host-resolver rule. +const SITE_NAME = SITE_HOST.split(":")[0]; + +// Page navigations use browser DNS -> Chromium resolver rules. +const PAGE_BASE = process.env.BASE_URL || `http://${SITE_HOST}`; + +export default defineConfig({ + testDir: "./e2e/tests", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "html", + timeout: 60000, + + expect: { + timeout: 10000, + }, + + use: { + baseURL: PAGE_BASE, + trace: "on-first-retry", + video: "retain-on-failure", + screenshot: "only-on-failure", + actionTimeout: 15000, + navigationTimeout: 30000, + launchOptions: { + args: [`--host-resolver-rules=MAP ${SITE_NAME} 127.0.0.1`], + }, + }, + + projects: [ + { + name: "setup", + testMatch: "**/auth.setup.ts", + }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: authFile, + }, + dependencies: ["setup"], + }, + ], +});