From 29dce1b18fbe6539b2a7bfbcf15735d539758678 Mon Sep 17 00:00:00 2001 From: Rahul Agrawal <12agrawalrahul@gmail.com> Date: Mon, 5 Oct 2026 22:21:39 +0530 Subject: [PATCH 01/10] test(e2e): add playwright config, helpers and fixture seed --- frontend/e2e/.env.e2e.example | 5 ++ frontend/e2e/.gitignore | 4 ++ frontend/e2e/helpers/api.ts | 97 ++++++++++++++++++++++++++ frontend/e2e/helpers/cleanup.ts | 58 +++++++++++++++ frontend/e2e/helpers/env.ts | 21 ++++++ frontend/e2e/helpers/pdf.ts | 46 ++++++++++++ frontend/e2e/helpers/test.ts | 32 +++++++++ frontend/e2e/helpers/upload.ts | 24 +++++++ frontend/e2e/helpers/wait.ts | 50 +++++++++++++ frontend/e2e/helpers/wikify.ts | 94 +++++++++++++++++++++++++ frontend/e2e/playwright.config.ts | 40 +++++++++++ frontend/e2e/setup/auth.setup.ts | 9 +++ frontend/e2e/setup/cleanup.teardown.ts | 14 ++++ frontend/e2e/setup/seed.setup.ts | 70 +++++++++++++++++++ frontend/package.json | 1 + frontend/yarn.lock | 19 +++++ 16 files changed, 584 insertions(+) create mode 100644 frontend/e2e/.env.e2e.example create mode 100644 frontend/e2e/.gitignore create mode 100644 frontend/e2e/helpers/api.ts create mode 100644 frontend/e2e/helpers/cleanup.ts create mode 100644 frontend/e2e/helpers/env.ts create mode 100644 frontend/e2e/helpers/pdf.ts create mode 100644 frontend/e2e/helpers/test.ts create mode 100644 frontend/e2e/helpers/upload.ts create mode 100644 frontend/e2e/helpers/wait.ts create mode 100644 frontend/e2e/helpers/wikify.ts create mode 100644 frontend/e2e/playwright.config.ts create mode 100644 frontend/e2e/setup/auth.setup.ts create mode 100644 frontend/e2e/setup/cleanup.teardown.ts create mode 100644 frontend/e2e/setup/seed.setup.ts diff --git a/frontend/e2e/.env.e2e.example b/frontend/e2e/.env.e2e.example new file mode 100644 index 0000000..ef8ca7f --- /dev/null +++ b/frontend/e2e/.env.e2e.example @@ -0,0 +1,5 @@ +E2E_BASE_URL=http://wikify.localhost:8010 +E2E_USER=e2e@example.com +E2E_PASSWORD= +E2E_API_KEY= +E2E_API_SECRET= diff --git a/frontend/e2e/.gitignore b/frontend/e2e/.gitignore new file mode 100644 index 0000000..eefd6c8 --- /dev/null +++ b/frontend/e2e/.gitignore @@ -0,0 +1,4 @@ +.env.e2e +.state/ +test-results/ +playwright-report/ diff --git a/frontend/e2e/helpers/api.ts b/frontend/e2e/helpers/api.ts new file mode 100644 index 0000000..7f1cfaa --- /dev/null +++ b/frontend/e2e/helpers/api.ts @@ -0,0 +1,97 @@ +import { request, type APIRequestContext, type APIResponse } from "@playwright/test"; +import { env } from "./env"; + +export type Row = Record; + +export class Api { + constructor(readonly context: APIRequestContext) {} + + static async create(): Promise { + const context = await request.newContext({ + baseURL: env.baseURL, + storageState: { cookies: [], origins: [] }, + extraHTTPHeaders: { Authorization: `token ${env.apiKey}:${env.apiSecret}` }, + }); + return new Api(context); + } + + async call(method: string, args: Row = {}): Promise { + const response = await this.context.post(`/api/method/${method}`, { data: args }); + return (await readJson(response, method)).message as T; + } + + async getValue(doctype: string, name: string | Row, fields: string | string[]): Promise { + const message = await this.call("frappe.client.get_value", { + doctype, + filters: name, + fieldname: fields, + }); + return (typeof fields === "string" ? message?.[fields] : message) as T; + } + + async getList( + doctype: string, + { + filters = {}, + fields = ["name"], + orderBy = "creation desc", + limit = 0, + }: { filters?: Row | any[]; fields?: string[]; orderBy?: string; limit?: number } = {}, + ): Promise { + return this.call("frappe.client.get_list", { + doctype, + filters, + fields, + order_by: orderBy, + limit_page_length: limit, + }); + } + + async getDoc(doctype: string, name: string): Promise { + const response = await this.context.get(`/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`); + return (await readJson(response, `${doctype} ${name}`)).data as T; + } + + async setValue(doctype: string, name: string, values: Row): Promise { + const response = await this.context.put(`/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, { + data: values, + }); + return (await readJson(response, `${doctype} ${name}`)).data; + } + + async delete(doctype: string, name: string): Promise { + const response = await this.context.delete(`/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`); + await readJson(response, `delete ${doctype} ${name}`); + } + + async dispose(): Promise { + await this.context.dispose(); + } +} + +export async function readJson(response: APIResponse, label: string): Promise { + const text = await response.text(); + let body: Row = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text.slice(0, 500) }; + } + if (!response.ok()) { + throw new Error(`${label} failed with HTTP ${response.status()}: ${serverMessage(body)}`); + } + return body; +} + +export function serverMessage(body: Row): string { + if (body._server_messages) { + try { + return JSON.parse(body._server_messages) + .map((message: string) => JSON.parse(message).message) + .join("; "); + } catch { + return body._server_messages; + } + } + return body.exception || body.exc_type || body.raw || JSON.stringify(body).slice(0, 500); +} diff --git a/frontend/e2e/helpers/cleanup.ts b/frontend/e2e/helpers/cleanup.ts new file mode 100644 index 0000000..4139321 --- /dev/null +++ b/frontend/e2e/helpers/cleanup.ts @@ -0,0 +1,58 @@ +import type { Api } from "./api"; + +async function tryDelete(api: Api, doctype: string, name: string): Promise { + try { + await api.delete(doctype, name); + } catch (error) { + console.warn(`cleanup: could not delete ${doctype} ${name}: ${(error as Error).message}`); + } +} + +export async function deleteByPrefix(api: Api, doctype: string, titleField: string, prefix: string): Promise { + const rows = await api.getList(doctype, { filters: { [titleField]: ["like", `${prefix}%`] } }); + for (const row of rows) await tryDelete(api, doctype, row.name); + return rows.map((row) => row.name); +} + +async function deleteTree(api: Api, doctype: string, filters: Record): Promise { + const rows = await api.getList(doctype, { filters, fields: ["name"], orderBy: "lft desc" }); + for (const row of rows) await tryDelete(api, doctype, row.name); +} + +export async function deleteImport(api: Api, importName: string): Promise { + const imp = await api.getValue("Wikify Import", importName, ["source_document"]); + const sourceDocument = imp?.source_document; + await deleteByFilter(api, "Import Log Entry", { import: importName }); + if (sourceDocument) { + await deleteByFilter(api, "Wikify Agent Session", { source_document: sourceDocument }); + await deleteTree(api, "Source Section", { source_document: sourceDocument }); + await deleteByFilter(api, "Section Reference", { source_document: sourceDocument }); + await deleteByFilter(api, "Source Page", { source_document: sourceDocument }); + await api.setValue("Wikify Import", importName, { source_document: null }); + await api.setValue("Source Document", sourceDocument, { wiki_root_group: null, wiki_space: null, import: null }); + await tryDelete(api, "Source Document", sourceDocument); + } + await tryDelete(api, "Wikify Import", importName); +} + +async function deleteByFilter(api: Api, doctype: string, filters: Record): Promise { + const rows = await api.getList(doctype, { filters }); + for (const row of rows) await tryDelete(api, doctype, row.name); +} + +export async function deleteWikiSpace(api: Api, wikiSpace: string): Promise { + await tryDelete(api, "Wiki Space", wikiSpace); +} + +export async function deleteTestProjects(api: Api, prefix: string): Promise { + const projects = await api.getList("Wikify Project", { filters: { project_name: ["like", `${prefix}%`] } }); + for (const project of projects) { + const imports = await api.getList("Wikify Import", { filters: { project: project.name } }); + for (const imp of imports) await deleteImport(api, imp.name); + await deleteByFilter(api, "Wikify Ask Session", { project: project.name }); + await deleteByFilter(api, "Wikify Agent Session", { project: project.name }); + await tryDelete(api, "Wikify Project", project.name); + } + const spaces = await api.getList("Wiki Space", { filters: { space_name: ["like", `${prefix}%`] } }); + for (const space of spaces) await deleteWikiSpace(api, space.name); +} diff --git a/frontend/e2e/helpers/env.ts b/frontend/e2e/helpers/env.ts new file mode 100644 index 0000000..c78c1a0 --- /dev/null +++ b/frontend/e2e/helpers/env.ts @@ -0,0 +1,21 @@ +import path from "node:path"; + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is not set. Copy e2e/.env.e2e.example to e2e/.env.e2e and fill it in.`); + return value; +} + +export const env = { + baseURL: required("E2E_BASE_URL"), + user: required("E2E_USER"), + password: required("E2E_PASSWORD"), + apiKey: required("E2E_API_KEY"), + apiSecret: required("E2E_API_SECRET"), +}; + +export const PREFIX = "[test]"; +export const FIXTURE_PREFIX = "[fixture]"; + +export const STATE_DIR = path.resolve(import.meta.dirname, "../.state"); +export const FIXTURE_PDF = path.resolve(import.meta.dirname, "../../../docs/what-is-wikify.pdf"); diff --git a/frontend/e2e/helpers/pdf.ts b/frontend/e2e/helpers/pdf.ts new file mode 100644 index 0000000..cdd2816 --- /dev/null +++ b/frontend/e2e/helpers/pdf.ts @@ -0,0 +1,46 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function makePdf(lines: string[]): Buffer { + const escape = (text: string) => text.replace(/[\\()]/g, (char) => `\\${char}`); + const content = [ + "BT /F1 14 Tf 72 740 Td 18 TL", + ...lines.map((line, index) => `${index ? "T* " : ""}(${escape(line)}) Tj`), + "ET", + ].join("\n"); + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ]; + let body = "%PDF-1.4\n"; + const offsets: number[] = []; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(body)); + body += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + const xref = Buffer.byteLength(body); + body += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + body += offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join(""); + body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return Buffer.from(body); +} + +export function writePdfs(dir: string, names: string[], lines: string[] = defaultLines()): string[] { + fs.mkdirSync(dir, { recursive: true }); + return names.map((name) => { + const file = path.join(dir, name.endsWith(".pdf") ? name : `${name}.pdf`); + fs.writeFileSync(file, makePdf([`${path.basename(file, ".pdf")}`, ...lines])); + return file; + }); +} + +function defaultLines(): string[] { + return [ + "Wikify end-to-end test document.", + "This one-page PDF is generated by the Playwright suite.", + "It has a clean text layer, so it parses quickly.", + ]; +} diff --git a/frontend/e2e/helpers/test.ts b/frontend/e2e/helpers/test.ts new file mode 100644 index 0000000..ca451eb --- /dev/null +++ b/frontend/e2e/helpers/test.ts @@ -0,0 +1,32 @@ +import fs from "node:fs"; +import path from "node:path"; +import { test as base } from "@playwright/test"; +import { Api } from "./api"; +import { STATE_DIR } from "./env"; + +export type ImportFixture = { import: string; sourceDocument: string; title: string }; + +export type Fixtures = { + project: string; + projectName: string; + review: ImportFixture; + agent: ImportFixture; +}; + +export const FIXTURES_FILE = path.join(STATE_DIR, "fixtures.json"); + +export const test = base.extend<{ fixture: Fixtures }, { api: Api }>({ + api: [ + async ({}, use) => { + const api = await Api.create(); + await use(api); + await api.dispose(); + }, + { scope: "worker" }, + ], + fixture: async ({}, use) => { + await use(JSON.parse(fs.readFileSync(FIXTURES_FILE, "utf8"))); + }, +}); + +export { expect } from "@playwright/test"; diff --git a/frontend/e2e/helpers/upload.ts b/frontend/e2e/helpers/upload.ts new file mode 100644 index 0000000..2ab2f5b --- /dev/null +++ b/frontend/e2e/helpers/upload.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Locator, Page } from "@playwright/test"; +import { readJson, type Api } from "./api"; + +export async function uploadViaUi(page: Page, input: Locator | string, files: string | string[]): Promise { + const locator = typeof input === "string" ? page.locator(input) : input; + await locator.setInputFiles(files); +} + +export async function uploadFile( + api: Api, + filePath: string, + { doctype, docname, fileName, isPrivate = true }: { doctype?: string; docname?: string; fileName?: string; isPrivate?: boolean } = {}, +): Promise { + const multipart: Record = { + file: { name: fileName || path.basename(filePath), mimeType: "application/pdf", buffer: fs.readFileSync(filePath) }, + is_private: isPrivate ? "1" : "0", + }; + if (doctype) multipart.doctype = doctype; + if (docname) multipart.docname = docname; + const response = await api.context.post("/api/method/upload_file", { multipart }); + return (await readJson(response, `upload ${filePath}`)).message.file_url; +} diff --git a/frontend/e2e/helpers/wait.ts b/frontend/e2e/helpers/wait.ts new file mode 100644 index 0000000..bf85523 --- /dev/null +++ b/frontend/e2e/helpers/wait.ts @@ -0,0 +1,50 @@ +import type { Api } from "./api"; + +type Expected = string | number | string[] | ((value: any) => boolean); + +function matches(value: any, expected: Expected): boolean { + if (typeof expected === "function") return expected(value); + if (Array.isArray(expected)) return expected.includes(value); + return value === expected; +} + +export async function waitForValue( + api: Api, + doctype: string, + name: string, + field: string, + expected: Expected, + { timeout = 300_000, interval = 3_000, errorField = "error" }: { timeout?: number; interval?: number; errorField?: string } = {}, +): Promise { + const deadline = Date.now() + timeout; + let last: any; + while (Date.now() < deadline) { + const row = await api.getValue(doctype, name, errorField ? [field, errorField] : [field]); + last = row?.[field]; + if (matches(last, expected)) return last; + if (row?.[errorField] && !matches(last, expected)) { + throw new Error(`${doctype} ${name}: ${field} = ${JSON.stringify(last)}, ${errorField} = ${row[errorField]}`); + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + const error = errorField ? await api.getValue(doctype, name, errorField) : undefined; + throw new Error( + `${doctype} ${name}: ${field} never matched within ${timeout / 1000}s; last value ${JSON.stringify(last)}` + + (error ? `, ${errorField} = ${error}` : ""), + ); +} + +export async function waitFor( + read: () => Promise, + done: (value: T) => boolean, + { timeout = 60_000, interval = 2_000, label = "condition" }: { timeout?: number; interval?: number; label?: string } = {}, +): Promise { + const deadline = Date.now() + timeout; + let last: T = await read(); + while (!done(last)) { + if (Date.now() > deadline) throw new Error(`${label} not met within ${timeout / 1000}s; last value ${JSON.stringify(last)}`); + await new Promise((resolve) => setTimeout(resolve, interval)); + last = await read(); + } + return last; +} diff --git a/frontend/e2e/helpers/wikify.ts b/frontend/e2e/helpers/wikify.ts new file mode 100644 index 0000000..aa9ce99 --- /dev/null +++ b/frontend/e2e/helpers/wikify.ts @@ -0,0 +1,94 @@ +import path from "node:path"; +import type { Api, Row } from "./api"; +import { FIXTURE_PDF } from "./env"; +import { uploadFile } from "./upload"; +import { waitForValue } from "./wait"; + +export const PARSE_TIMEOUT = 600_000; + +export type SectionRow = { + name: string; + title: string; + parent_source_section: string | null; + sort_order: number; + level: number; + is_group: number; + include_in_wiki: number; + section_type: string | null; + wiki_document: string | null; + page_start: number; + page_end: number; + lft: number; + markdown: string; +}; + +export async function createProject(api: Api, projectName: string): Promise { + return api.call("wikify.api.projects.create_project", { project_name: projectName }); +} + +export async function findProject(api: Api, projectName: string): Promise { + const rows = await api.getList("Wikify Project", { filters: { project_name: projectName }, limit: 1 }); + return rows[0]?.name; +} + +export async function startImport( + api: Api, + { title, project, pdf = FIXTURE_PDF, fileName }: { title: string; project: string; pdf?: string; fileName?: string }, +): Promise { + const uniqueName = fileName || `${path.basename(pdf, ".pdf")}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.pdf`; + const fileUrl = await uploadFile(api, pdf, { fileName: uniqueName }); + return api.call("wikify.api.imports.start_import", { pdf_file_url: fileUrl, title, project }); +} + +export async function waitForImport(api: Api, importName: string, status: string | string[], timeout = PARSE_TIMEOUT) { + await waitForValue(api, "Wikify Import", importName, "status", status, { timeout, interval: 5_000 }); + return api.getValue("Wikify Import", importName, ["status", "stage_label", "error", "source_document", "wiki_space"]); +} + +export async function sectionRows(api: Api, sourceDocument: string): Promise { + return api.getList("Source Section", { + filters: { source_document: sourceDocument }, + fields: [ + "name", + "title", + "parent_source_section", + "sort_order", + "level", + "is_group", + "include_in_wiki", + "section_type", + "wiki_document", + "page_start", + "page_end", + "lft", + "markdown", + ], + orderBy: "lft asc", + }); +} + +export async function findSection(api: Api, sourceDocument: string, title: string): Promise { + const section = (await sectionRows(api, sourceDocument)).find((row) => row.title === title); + if (!section) throw new Error(`No section titled "${title}" in ${sourceDocument}`); + return section; +} + +export async function pageRows(api: Api, sourceDocument: string): Promise { + return api.getList("Source Page", { + filters: { source_document: sourceDocument }, + fields: ["name", "page_no", "verdict", "composite", "canonical_composite", "canonical_source", "canonical_markdown"], + orderBy: "page_no asc", + }); +} + +export const FIXTURE_ROOT = "What Wikify Is"; +export const FIXTURE_SECTIONS = [ + "Where your documents live", + "How Wikify processes a PDF", + "Reading the page review", + "Sections and their labels", + "Finding content across documents", + "Generating the wiki", + "The assistant", + "Which AI service Wikify uses", +]; diff --git a/frontend/e2e/playwright.config.ts b/frontend/e2e/playwright.config.ts new file mode 100644 index 0000000..aba7432 --- /dev/null +++ b/frontend/e2e/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from "@playwright/test"; + +process.loadEnvFile("e2e/.env.e2e"); + +export default defineConfig({ + testDir: "./specs", + outputDir: "./test-results", + workers: 1, + fullyParallel: false, + forbidOnly: !!process.env.CI, + reporter: [["list"], ["html", { outputFolder: "./playwright-report", open: "never" }]], + use: { + baseURL: process.env.E2E_BASE_URL, + viewport: { width: 1440, height: 1000 }, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "setup", + testDir: "./setup", + testMatch: /.*\.setup\.ts/, + teardown: "cleanup", + }, + { + name: "cleanup", + testDir: "./setup", + testMatch: /.*\.teardown\.ts/, + }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1440, height: 1000 }, + storageState: "e2e/.state/user.json", + }, + dependencies: ["setup"], + }, + ], +}); diff --git a/frontend/e2e/setup/auth.setup.ts b/frontend/e2e/setup/auth.setup.ts new file mode 100644 index 0000000..11104a6 --- /dev/null +++ b/frontend/e2e/setup/auth.setup.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { expect, test as setup } from "@playwright/test"; +import { env, STATE_DIR } from "../helpers/env"; + +setup("log in as the e2e user", async ({ request }) => { + const response = await request.post("/api/method/login", { data: { usr: env.user, pwd: env.password } }); + expect(response.ok(), await response.text()).toBeTruthy(); + await request.storageState({ path: path.join(STATE_DIR, "user.json") }); +}); diff --git a/frontend/e2e/setup/cleanup.teardown.ts b/frontend/e2e/setup/cleanup.teardown.ts new file mode 100644 index 0000000..5ce2dbf --- /dev/null +++ b/frontend/e2e/setup/cleanup.teardown.ts @@ -0,0 +1,14 @@ +import { test as teardown } from "@playwright/test"; +import { Api } from "../helpers/api"; +import { deleteTestProjects } from "../helpers/cleanup"; +import { PREFIX } from "../helpers/env"; + +teardown("delete leftover [test] records", async () => { + teardown.setTimeout(300_000); + const api = await Api.create(); + try { + await deleteTestProjects(api, PREFIX); + } finally { + await api.dispose(); + } +}); diff --git a/frontend/e2e/setup/seed.setup.ts b/frontend/e2e/setup/seed.setup.ts new file mode 100644 index 0000000..c6baaaa --- /dev/null +++ b/frontend/e2e/setup/seed.setup.ts @@ -0,0 +1,70 @@ +import fs from "node:fs"; +import { test as setup } from "@playwright/test"; +import { Api } from "../helpers/api"; +import { deleteImport } from "../helpers/cleanup"; +import { FIXTURE_PREFIX, STATE_DIR } from "../helpers/env"; +import { FIXTURES_FILE, type Fixtures, type ImportFixture } from "../helpers/test"; +import { + createProject, + FIXTURE_ROOT, + FIXTURE_SECTIONS, + findProject, + PARSE_TIMEOUT, + sectionRows, + startImport, + waitForImport, +} from "../helpers/wikify"; + +const PROJECT_NAME = `${FIXTURE_PREFIX} e2e`; +const IN_PROGRESS = ["Queued", "Parsing", "Remediating"]; + +async function isPristine(api: Api, sourceDocument: string): Promise { + const rows = await sectionRows(api, sourceDocument); + const root = rows.find((row) => row.title === FIXTURE_ROOT && !row.parent_source_section); + const children = rows.filter((row) => root && row.parent_source_section === root.name).map((row) => row.title); + return ( + !!root && + rows.length === FIXTURE_SECTIONS.length + 1 && + JSON.stringify(children) === JSON.stringify(FIXTURE_SECTIONS) && + rows.every((row) => row.include_in_wiki === 1) + ); +} + +async function seedImport(api: Api, project: string, title: string): Promise { + const existing = await api.getList("Wikify Import", { + filters: { import_title: title, project }, + fields: ["name", "status", "source_document"], + }); + for (const imp of existing) { + if (imp.status === "Review" && imp.source_document && (await isPristine(api, imp.source_document))) { + return { import: imp.name, sourceDocument: imp.source_document, title }; + } + if (IN_PROGRESS.includes(imp.status)) { + const done = await waitForImport(api, imp.name, ["Review", "Failed"]); + if (done.status === "Review" && (await isPristine(api, done.source_document))) { + return { import: imp.name, sourceDocument: done.source_document, title }; + } + } + await deleteImport(api, imp.name); + } + const name = await startImport(api, { title, project }); + const done = await waitForImport(api, name, "Review"); + return { import: name, sourceDocument: done.source_document, title }; +} + +setup("seed the fixture project and imports", async () => { + setup.setTimeout(PARSE_TIMEOUT + 120_000); + const api = await Api.create(); + try { + const project = (await findProject(api, PROJECT_NAME)) || (await createProject(api, PROJECT_NAME)); + const [review, agent] = await Promise.all([ + seedImport(api, project, `${FIXTURE_PREFIX} review`), + seedImport(api, project, `${FIXTURE_PREFIX} agent`), + ]); + const fixtures: Fixtures = { project, projectName: PROJECT_NAME, review, agent }; + fs.mkdirSync(STATE_DIR, { recursive: true }); + fs.writeFileSync(FIXTURES_FILE, JSON.stringify(fixtures, null, "\t")); + } finally { + await api.dispose(); + } +}); diff --git a/frontend/package.json b/frontend/package.json index 9752742..c4c4e1a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "vue-router": "^4.5.0" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@vitejs/plugin-vue": "^6.0.5", "autoprefixer": "^10.4.2", "lucide-static": "^0.469.0", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 3efaba3..e6676a7 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -681,6 +681,13 @@ resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.133.0.tgz#2e282ef9e1d26e06b68ccd14b73f310a3b2cf7f8" integrity sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA== +"@playwright/test@^1.63.0": + version "1.63.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.63.0.tgz#808020c46e8b6b91f9d628cd0ebcbb32bdfd8b6c" + integrity sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ== + dependencies: + playwright "1.63.0" + "@popperjs/core@^2.11.2", "@popperjs/core@^2.9.0": version "2.11.8" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" @@ -2844,6 +2851,18 @@ pkg-types@^2.1.0, pkg-types@^2.3.0: exsolve "^1.0.8" pathe "^2.0.3" +playwright-core@1.63.0: + version "1.63.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.63.0.tgz#e57665bc32846c213ac39a1e4d5bc6228e76b376" + integrity sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg== + +playwright@1.63.0: + version "1.63.0" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.63.0.tgz#99b56f9f69b1b70c44f00bf84b2fe52348ae2511" + integrity sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg== + dependencies: + playwright-core "1.63.0" + points-on-curve@0.2.0, points-on-curve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/points-on-curve/-/points-on-curve-0.2.0.tgz#7dbb98c43791859434284761330fa893cb81b4d1" From 2879e08f7598d0f195451d52478978700ec83b8f Mon Sep 17 00:00:00 2001 From: Rahul Agrawal <12agrawalrahul@gmail.com> Date: Tue, 6 Oct 2026 01:06:01 +0530 Subject: [PATCH 02/10] test(e2e): add smoke, tree, crop, publish, upload, parse and ask specs --- frontend/e2e/helpers/api.ts | 23 +- frontend/e2e/helpers/cleanup.ts | 65 +++- frontend/e2e/helpers/env.ts | 5 +- frontend/e2e/helpers/pdf.ts | 6 +- frontend/e2e/helpers/upload.ts | 19 +- frontend/e2e/helpers/wait.ts | 21 +- frontend/e2e/helpers/wikify.ts | 52 +++- frontend/e2e/setup/auth.setup.ts | 4 +- frontend/e2e/setup/seed.setup.ts | 13 +- frontend/e2e/specs/ask.spec.ts | 300 +++++++++++++++++++ frontend/e2e/specs/crop.spec.ts | 436 +++++++++++++++++++++++++++ frontend/e2e/specs/parse.spec.ts | 373 +++++++++++++++++++++++ frontend/e2e/specs/publish.spec.ts | 333 +++++++++++++++++++++ frontend/e2e/specs/smoke.spec.ts | 459 +++++++++++++++++++++++++++++ frontend/e2e/specs/tree.spec.ts | 459 +++++++++++++++++++++++++++++ frontend/e2e/specs/upload.spec.ts | 300 +++++++++++++++++++ 16 files changed, 2831 insertions(+), 37 deletions(-) create mode 100644 frontend/e2e/specs/ask.spec.ts create mode 100644 frontend/e2e/specs/crop.spec.ts create mode 100644 frontend/e2e/specs/parse.spec.ts create mode 100644 frontend/e2e/specs/publish.spec.ts create mode 100644 frontend/e2e/specs/smoke.spec.ts create mode 100644 frontend/e2e/specs/tree.spec.ts create mode 100644 frontend/e2e/specs/upload.spec.ts diff --git a/frontend/e2e/helpers/api.ts b/frontend/e2e/helpers/api.ts index 7f1cfaa..8d6c7fd 100644 --- a/frontend/e2e/helpers/api.ts +++ b/frontend/e2e/helpers/api.ts @@ -20,7 +20,11 @@ export class Api { return (await readJson(response, method)).message as T; } - async getValue(doctype: string, name: string | Row, fields: string | string[]): Promise { + async getValue( + doctype: string, + name: string | Row, + fields: string | string[], + ): Promise { const message = await this.call("frappe.client.get_value", { doctype, filters: name, @@ -48,19 +52,26 @@ export class Api { } async getDoc(doctype: string, name: string): Promise { - const response = await this.context.get(`/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`); + const response = await this.context.get( + `/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, + ); return (await readJson(response, `${doctype} ${name}`)).data as T; } async setValue(doctype: string, name: string, values: Row): Promise { - const response = await this.context.put(`/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, { - data: values, - }); + const response = await this.context.put( + `/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, + { + data: values, + }, + ); return (await readJson(response, `${doctype} ${name}`)).data; } async delete(doctype: string, name: string): Promise { - const response = await this.context.delete(`/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`); + const response = await this.context.delete( + `/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, + ); await readJson(response, `delete ${doctype} ${name}`); } diff --git a/frontend/e2e/helpers/cleanup.ts b/frontend/e2e/helpers/cleanup.ts index 4139321..938bd83 100644 --- a/frontend/e2e/helpers/cleanup.ts +++ b/frontend/e2e/helpers/cleanup.ts @@ -1,14 +1,32 @@ import type { Api } from "./api"; +import { waitFor } from "./wait"; -async function tryDelete(api: Api, doctype: string, name: string): Promise { - try { - await api.delete(doctype, name); - } catch (error) { - console.warn(`cleanup: could not delete ${doctype} ${name}: ${(error as Error).message}`); +const BUSY = ["Queued", "Parsing", "Remediating", "Generating Wiki"]; + +// The search-index rebuild that follows a parse can still hold row locks, so retry deadlocks. +async function tryDelete(api: Api, doctype: string, name: string, attempts = 4): Promise { + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + await api.delete(doctype, name); + return; + } catch (error) { + const message = (error as Error).message; + if (attempt < attempts && /HTTP 508|Deadlock|modified/i.test(message)) { + await new Promise((resolve) => setTimeout(resolve, 3_000 * attempt)); + continue; + } + console.warn(`cleanup: could not delete ${doctype} ${name}: ${message}`); + return; + } } } -export async function deleteByPrefix(api: Api, doctype: string, titleField: string, prefix: string): Promise { +export async function deleteByPrefix( + api: Api, + doctype: string, + titleField: string, + prefix: string, +): Promise { const rows = await api.getList(doctype, { filters: { [titleField]: ["like", `${prefix}%`] } }); for (const row of rows) await tryDelete(api, doctype, row.name); return rows.map((row) => row.name); @@ -19,7 +37,22 @@ async function deleteTree(api: Api, doctype: string, filters: Record { + await waitFor( + () => api.getValue("Wikify Import", importName, "status"), + (status) => !BUSY.includes(status), + { timeout, interval: 5_000, label: `${importName} idle` }, + ).catch((error) => console.warn(`cleanup: ${error.message}`)); +} + export async function deleteImport(api: Api, importName: string): Promise { + await waitForIdle(api, importName); const imp = await api.getValue("Wikify Import", importName, ["source_document"]); const sourceDocument = imp?.source_document; await deleteByFilter(api, "Import Log Entry", { import: importName }); @@ -29,13 +62,21 @@ export async function deleteImport(api: Api, importName: string): Promise await deleteByFilter(api, "Section Reference", { source_document: sourceDocument }); await deleteByFilter(api, "Source Page", { source_document: sourceDocument }); await api.setValue("Wikify Import", importName, { source_document: null }); - await api.setValue("Source Document", sourceDocument, { wiki_root_group: null, wiki_space: null, import: null }); + await api.setValue("Source Document", sourceDocument, { + wiki_root_group: null, + wiki_space: null, + import: null, + }); await tryDelete(api, "Source Document", sourceDocument); } await tryDelete(api, "Wikify Import", importName); } -async function deleteByFilter(api: Api, doctype: string, filters: Record): Promise { +async function deleteByFilter( + api: Api, + doctype: string, + filters: Record, +): Promise { const rows = await api.getList(doctype, { filters }); for (const row of rows) await tryDelete(api, doctype, row.name); } @@ -45,7 +86,9 @@ export async function deleteWikiSpace(api: Api, wikiSpace: string): Promise { - const projects = await api.getList("Wikify Project", { filters: { project_name: ["like", `${prefix}%`] } }); + const projects = await api.getList("Wikify Project", { + filters: { project_name: ["like", `${prefix}%`] }, + }); for (const project of projects) { const imports = await api.getList("Wikify Import", { filters: { project: project.name } }); for (const imp of imports) await deleteImport(api, imp.name); @@ -53,6 +96,8 @@ export async function deleteTestProjects(api: Api, prefix: string): Promise { const file = path.join(dir, name.endsWith(".pdf") ? name : `${name}.pdf`); diff --git a/frontend/e2e/helpers/upload.ts b/frontend/e2e/helpers/upload.ts index 2ab2f5b..7ef0ed7 100644 --- a/frontend/e2e/helpers/upload.ts +++ b/frontend/e2e/helpers/upload.ts @@ -3,7 +3,11 @@ import path from "node:path"; import type { Locator, Page } from "@playwright/test"; import { readJson, type Api } from "./api"; -export async function uploadViaUi(page: Page, input: Locator | string, files: string | string[]): Promise { +export async function uploadViaUi( + page: Page, + input: Locator | string, + files: string | string[], +): Promise { const locator = typeof input === "string" ? page.locator(input) : input; await locator.setInputFiles(files); } @@ -11,10 +15,19 @@ export async function uploadViaUi(page: Page, input: Locator | string, files: st export async function uploadFile( api: Api, filePath: string, - { doctype, docname, fileName, isPrivate = true }: { doctype?: string; docname?: string; fileName?: string; isPrivate?: boolean } = {}, + { + doctype, + docname, + fileName, + isPrivate = true, + }: { doctype?: string; docname?: string; fileName?: string; isPrivate?: boolean } = {}, ): Promise { const multipart: Record = { - file: { name: fileName || path.basename(filePath), mimeType: "application/pdf", buffer: fs.readFileSync(filePath) }, + file: { + name: fileName || path.basename(filePath), + mimeType: "application/pdf", + buffer: fs.readFileSync(filePath), + }, is_private: isPrivate ? "1" : "0", }; if (doctype) multipart.doctype = doctype; diff --git a/frontend/e2e/helpers/wait.ts b/frontend/e2e/helpers/wait.ts index bf85523..b062e6c 100644 --- a/frontend/e2e/helpers/wait.ts +++ b/frontend/e2e/helpers/wait.ts @@ -14,7 +14,11 @@ export async function waitForValue( name: string, field: string, expected: Expected, - { timeout = 300_000, interval = 3_000, errorField = "error" }: { timeout?: number; interval?: number; errorField?: string } = {}, + { + timeout = 300_000, + interval = 3_000, + errorField = "error", + }: { timeout?: number; interval?: number; errorField?: string } = {}, ): Promise { const deadline = Date.now() + timeout; let last: any; @@ -23,7 +27,9 @@ export async function waitForValue( last = row?.[field]; if (matches(last, expected)) return last; if (row?.[errorField] && !matches(last, expected)) { - throw new Error(`${doctype} ${name}: ${field} = ${JSON.stringify(last)}, ${errorField} = ${row[errorField]}`); + throw new Error( + `${doctype} ${name}: ${field} = ${JSON.stringify(last)}, ${errorField} = ${row[errorField]}`, + ); } await new Promise((resolve) => setTimeout(resolve, interval)); } @@ -37,12 +43,19 @@ export async function waitForValue( export async function waitFor( read: () => Promise, done: (value: T) => boolean, - { timeout = 60_000, interval = 2_000, label = "condition" }: { timeout?: number; interval?: number; label?: string } = {}, + { + timeout = 60_000, + interval = 2_000, + label = "condition", + }: { timeout?: number; interval?: number; label?: string } = {}, ): Promise { const deadline = Date.now() + timeout; let last: T = await read(); while (!done(last)) { - if (Date.now() > deadline) throw new Error(`${label} not met within ${timeout / 1000}s; last value ${JSON.stringify(last)}`); + if (Date.now() > deadline) + throw new Error( + `${label} not met within ${timeout / 1000}s; last value ${JSON.stringify(last)}`, + ); await new Promise((resolve) => setTimeout(resolve, interval)); last = await read(); } diff --git a/frontend/e2e/helpers/wikify.ts b/frontend/e2e/helpers/wikify.ts index aa9ce99..de619b4 100644 --- a/frontend/e2e/helpers/wikify.ts +++ b/frontend/e2e/helpers/wikify.ts @@ -27,22 +27,46 @@ export async function createProject(api: Api, projectName: string): Promise { - const rows = await api.getList("Wikify Project", { filters: { project_name: projectName }, limit: 1 }); + const rows = await api.getList("Wikify Project", { + filters: { project_name: projectName }, + limit: 1, + }); return rows[0]?.name; } export async function startImport( api: Api, - { title, project, pdf = FIXTURE_PDF, fileName }: { title: string; project: string; pdf?: string; fileName?: string }, + { + title, + project, + pdf = FIXTURE_PDF, + fileName, + }: { title: string; project: string; pdf?: string; fileName?: string }, ): Promise { - const uniqueName = fileName || `${path.basename(pdf, ".pdf")}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.pdf`; + const uniqueName = + fileName || + `${path.basename(pdf, ".pdf")}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.pdf`; const fileUrl = await uploadFile(api, pdf, { fileName: uniqueName }); return api.call("wikify.api.imports.start_import", { pdf_file_url: fileUrl, title, project }); } -export async function waitForImport(api: Api, importName: string, status: string | string[], timeout = PARSE_TIMEOUT) { - await waitForValue(api, "Wikify Import", importName, "status", status, { timeout, interval: 5_000 }); - return api.getValue("Wikify Import", importName, ["status", "stage_label", "error", "source_document", "wiki_space"]); +export async function waitForImport( + api: Api, + importName: string, + status: string | string[], + timeout = PARSE_TIMEOUT, +) { + await waitForValue(api, "Wikify Import", importName, "status", status, { + timeout, + interval: 5_000, + }); + return api.getValue("Wikify Import", importName, [ + "status", + "stage_label", + "error", + "source_document", + "wiki_space", + ]); } export async function sectionRows(api: Api, sourceDocument: string): Promise { @@ -67,7 +91,11 @@ export async function sectionRows(api: Api, sourceDocument: string): Promise { +export async function findSection( + api: Api, + sourceDocument: string, + title: string, +): Promise { const section = (await sectionRows(api, sourceDocument)).find((row) => row.title === title); if (!section) throw new Error(`No section titled "${title}" in ${sourceDocument}`); return section; @@ -76,7 +104,15 @@ export async function findSection(api: Api, sourceDocument: string, title: strin export async function pageRows(api: Api, sourceDocument: string): Promise { return api.getList("Source Page", { filters: { source_document: sourceDocument }, - fields: ["name", "page_no", "verdict", "composite", "canonical_composite", "canonical_source", "canonical_markdown"], + fields: [ + "name", + "page_no", + "verdict", + "composite", + "canonical_composite", + "canonical_source", + "canonical_markdown", + ], orderBy: "page_no asc", }); } diff --git a/frontend/e2e/setup/auth.setup.ts b/frontend/e2e/setup/auth.setup.ts index 11104a6..f9b6eb4 100644 --- a/frontend/e2e/setup/auth.setup.ts +++ b/frontend/e2e/setup/auth.setup.ts @@ -3,7 +3,9 @@ import { expect, test as setup } from "@playwright/test"; import { env, STATE_DIR } from "../helpers/env"; setup("log in as the e2e user", async ({ request }) => { - const response = await request.post("/api/method/login", { data: { usr: env.user, pwd: env.password } }); + const response = await request.post("/api/method/login", { + data: { usr: env.user, pwd: env.password }, + }); expect(response.ok(), await response.text()).toBeTruthy(); await request.storageState({ path: path.join(STATE_DIR, "user.json") }); }); diff --git a/frontend/e2e/setup/seed.setup.ts b/frontend/e2e/setup/seed.setup.ts index c6baaaa..1dba2eb 100644 --- a/frontend/e2e/setup/seed.setup.ts +++ b/frontend/e2e/setup/seed.setup.ts @@ -21,7 +21,9 @@ const IN_PROGRESS = ["Queued", "Parsing", "Remediating"]; async function isPristine(api: Api, sourceDocument: string): Promise { const rows = await sectionRows(api, sourceDocument); const root = rows.find((row) => row.title === FIXTURE_ROOT && !row.parent_source_section); - const children = rows.filter((row) => root && row.parent_source_section === root.name).map((row) => row.title); + const children = rows + .filter((row) => root && row.parent_source_section === root.name) + .map((row) => row.title); return ( !!root && rows.length === FIXTURE_SECTIONS.length + 1 && @@ -36,7 +38,11 @@ async function seedImport(api: Api, project: string, title: string): Promise { setup.setTimeout(PARSE_TIMEOUT + 120_000); const api = await Api.create(); try { - const project = (await findProject(api, PROJECT_NAME)) || (await createProject(api, PROJECT_NAME)); + const project = + (await findProject(api, PROJECT_NAME)) || (await createProject(api, PROJECT_NAME)); const [review, agent] = await Promise.all([ seedImport(api, project, `${FIXTURE_PREFIX} review`), seedImport(api, project, `${FIXTURE_PREFIX} agent`), diff --git a/frontend/e2e/specs/ask.spec.ts b/frontend/e2e/specs/ask.spec.ts new file mode 100644 index 0000000..acb0c01 --- /dev/null +++ b/frontend/e2e/specs/ask.spec.ts @@ -0,0 +1,300 @@ +import type { Page, Response } from "@playwright/test"; +import type { Api } from "../helpers/api"; +import { deleteByPrefix } from "../helpers/cleanup"; +import { PREFIX } from "../helpers/env"; +import { expect, test } from "../helpers/test"; + +const AREA_PREFIX = `${PREFIX} ask ${Date.now()}`; +const ANSWER_TIMEOUT = 180_000; + +type Counts = Record; + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function pickProject(page: Page, projectName: string): Promise { + await page.getByRole("combobox").click(); + await page.getByRole("option", { name: projectName, exact: true }).click(); + await expect(page.getByRole("combobox")).toHaveText(projectName); +} + +async function typeLabels(api: Api): Promise> { + const types = await api.getList("Section Type", { + fields: ["type_name", "label"], + orderBy: "creation asc", + }); + return Object.fromEntries(types.map((type) => [type.type_name, type.label || type.type_name])); +} + +// Section counts keyed by Section Type label ("Untagged" for none), and per source document for each label. +async function sectionCounts(api: Api, sourceDocuments: string[], labels: Record) { + const rows = await api.getList("Source Section", { + filters: { source_document: ["in", sourceDocuments] }, + fields: ["source_document", "section_type"], + }); + const byType: Counts = {}; + const byDocument: Record = {}; + for (const row of rows) { + const label = row.section_type ? labels[row.section_type] : "Untagged"; + byType[label] = (byType[label] || 0) + 1; + byDocument[label] ??= {}; + byDocument[label][row.source_document] = (byDocument[label][row.source_document] || 0) + 1; + } + return { total: rows.length, byType, byDocument }; +} + +function taggedOnly(byType: Counts): Counts { + return Object.fromEntries(Object.entries(byType).filter(([label]) => label !== "Untagged")); +} + +// Reads "