diff --git a/frontend/e2e/specs/crop.spec.ts b/frontend/e2e/specs/crop.spec.ts index 60d6607..d1e6066 100644 --- a/frontend/e2e/specs/crop.spec.ts +++ b/frontend/e2e/specs/crop.spec.ts @@ -439,3 +439,85 @@ test.describe("crop", () => { }, ); }); + +test.describe("PR #61 crop on a shared page", () => { + let before: Snapshot; + + test.beforeEach(async ({ api, fixture }) => { + before = await takeSnapshot(api, fixture.review.sourceDocument); + }); + + test.afterEach(async ({ api, fixture }) => { + test.setTimeout(test.info().timeout + PROPAGATION_TIMEOUT); + await restoreCrops(api, fixture.review.sourceDocument, before); + }); + + test( + "W61-1 a crop on a page two sections share changes only the section holding the image", + { tag: ["@functional", "@crop"] }, + async ({ api, fixture }) => { + test.setTimeout(PROPAGATION_TIMEOUT + 120_000); + const sourceDocument = fixture.review.sourceDocument; + let target: + | { + pageNo: number; + tag: string; + caption: string; + holder: SectionSnapshot; + others: SectionSnapshot[]; + } + | undefined; + for (const row of before.pages) { + const owners = owningSections(before.sections, row.page_no); + if (owners.length < 2) continue; + const figure = imageTags(row.canonical_markdown).find((tag) => !tag.caption.includes('"')); + if (!figure) continue; + const tag = `![${figure.caption}](${figure.url})`; + if (row.canonical_markdown.split(tag).length !== 2) continue; + const holders = owners.filter((owner) => owner.markdown.includes(tag)); + if (holders.length !== 1 || holders[0].markdown.split(tag).length !== 2) continue; + target = { + pageNo: row.page_no, + tag, + caption: figure.caption, + holder: holders[0], + others: owners.filter((owner) => owner.name !== holders[0].name), + }; + break; + } + test.skip(!target, "no page shared by two sections holds a figure in only one of them"); + const { pageNo, tag, caption, holder, others } = target!; + + const result = await api.call("wikify.api.pages.crop_page_figure", { + source_document: sourceDocument, + page_no: pageNo, + caption, + occurrence: 0, + x0: 0.25, + y0: 0.25, + x1: 0.75, + y1: 0.75, + }); + + const holderMarkdown = await waitFor( + () => api.getValue("Source Section", holder.name, "markdown"), + (markdown) => markdown.includes(result.image_url), + { timeout: 60_000, interval: 2_000, label: "holder section has the crop" }, + ); + expect(holderMarkdown).toBe( + holder.markdown.replace(tag, `![${caption}](${result.image_url})`), + ); + + await waitFor( + () => propagationPending(api, sourceDocument), + (pending) => !pending, + { timeout: PROPAGATION_TIMEOUT, interval: 3_000, label: "propagation settled" }, + ); + for (const other of others) { + expect(await api.getValue("Source Section", other.name, "markdown")).toBe( + other.markdown, + ); + } + }, + ); +}); diff --git a/frontend/e2e/specs/publish.spec.ts b/frontend/e2e/specs/publish.spec.ts index ca01034..49b0b87 100644 --- a/frontend/e2e/specs/publish.spec.ts +++ b/frontend/e2e/specs/publish.spec.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import type { Api } from "../helpers/api"; +import { serverMessage, type Api } from "../helpers/api"; import { PREFIX } from "../helpers/env"; import { expect, test } from "../helpers/test"; import { waitFor } from "../helpers/wait"; @@ -348,3 +348,119 @@ test.describe("publish", () => { }, ); }); + +test.describe("PR #19 lock regenerate and hand off to the wiki app editor", () => { + test.describe.configure({ mode: "serial" }); + + const stamp = Date.now(); + const name = `${PREFIX} publish W19 ${stamp}`; + const spaceRoute = `test-publish-w19-${stamp}`; + let importName = ""; + let space = ""; + let leaf: SectionRow; + + test.beforeAll(async ({ api, fixture }) => { + test.setTimeout(QUEUE_TIMEOUT + 600_000); + const project = await createProject(api, name); + const cloned = await cloneImport(api, fixture.source.import, { title: name, project }); + importName = cloned.import; + const { rows, children } = await outline(api, cloned.sourceDocument); + const leafName = children.find( + (child) => !rows.some((row) => row.parent_source_section === child.name), + )!.name; + await api.call("wikify.api.sections.build_graph", { import_name: importName }); + await waitForImport(api, importName, "Graphed", 120_000); + await api.call("wikify.api.imports.generate_wiki", { + import_name: importName, + new_space: { space_name: name, route: spaceRoute }, + }); + await waitForImport(api, importName, "Completed", QUEUE_TIMEOUT); + space = (await api.getList("Wiki Space", { filters: { route: spaceRoute } }))[0].name; + leaf = (await sectionRows(api, cloned.sourceDocument)).find((row) => row.name === leafName)!; + }); + + test( + "W19-1 clicking a page after publish opens it in the Wiki app editor", + { tag: ["@functional", "@publish"] }, + async ({ page }) => { + await page.goto(`/wikify/import/${importName}/tree`); + await expect(page.getByText("Published", { exact: true })).toBeVisible(); + const popupPromise = page.waitForEvent("popup"); + await page + .locator(`[data-section-row="${leaf.name}"]`) + .getByText(leaf.title, { exact: true }) + .click(); + const popup = await popupPromise; + expect(new URL(popup.url()).pathname).toBe( + `/wiki-app/spaces/${space}/page/${leaf.wiki_document}`, + ); + await popup.close(); + }, + ); + + test( + "W19-2 regenerating a published import is rejected", + { tag: ["@negative", "@publish"] }, + async ({ api }) => { + const before = await wikiDocuments(api, spaceRoute); + const response = await api.context.post("/api/method/wikify.api.imports.generate_wiki", { + data: { import_name: importName, wiki_space: space }, + }); + expect(response.status()).toBe(417); + expect(serverMessage(await response.json())).toContain( + "This wiki has already been published — edit pages directly in the Wiki app.", + ); + expect(await api.getValue("Wikify Import", importName, "status")).toBe("Completed"); + expect(await wikiDocuments(api, spaceRoute)).toEqual(before); + }, + ); +}); + +test.describe("PR #18 collapse tabs to pdf/pages/wiki/logs", () => { + test.describe.configure({ mode: "serial" }); + + const name = `${PREFIX} publish W18 ${Date.now()}`; + let importName = ""; + let sourceDocument = ""; + + test.beforeAll(async ({ api, fixture }) => { + test.setTimeout(900_000); + const project = await createProject(api, name); + const cloned = await cloneImport(api, fixture.source.import, { title: name, project }); + importName = cloned.import; + sourceDocument = cloned.sourceDocument; + await api.call("wikify.api.sections.build_graph", { import_name: importName }); + await waitForImport(api, importName, "Graphed", 120_000); + }); + + test( + "W18-1 the old wiki link lands on the merged Wiki tab with Publish", + { tag: ["@functional", "@publish"] }, + async ({ page }) => { + await page.goto(`/wikify/import/${importName}/wiki`); + await expect(page).toHaveURL(new RegExp(`/wikify/import/${importName}/tree$`)); + for (const label of ["PDF", "Pages", "Wiki", "Logs"]) + await expect(page.getByRole("tab", { name: label, exact: true })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Explore", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Publish", exact: true })).toBeEnabled(); + }, + ); + + test( + "W18-2 the publish dialog refuses to generate when no section is included", + { tag: ["@negative", "@publish"] }, + async ({ page, api }) => { + const rows = await sectionRows(api, sourceDocument); + for (const row of rows.filter((r) => r.include_in_wiki && !r.parent_source_section)) + await api.call("wikify.api.sections.toggle_include", { name: row.name, include: 0 }); + await waitFor( + async () => (await sectionRows(api, sourceDocument)).filter((r) => r.include_in_wiki), + (included) => included.length === 0, + { timeout: 60_000, interval: 2_000, label: "all sections excluded" }, + ); + const dialog = await openPublishDialog(page, importName); + await expect(dialog.getByText(/nothing to publish/)).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Generate wiki" })).toBeDisabled(); + }, + ); +}); diff --git a/frontend/e2e/specs/settings.spec.ts b/frontend/e2e/specs/settings.spec.ts new file mode 100644 index 0000000..65bbd3f --- /dev/null +++ b/frontend/e2e/specs/settings.spec.ts @@ -0,0 +1,74 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "../helpers/test"; + +const SINGLE = "Wikify Settings"; + +async function openModels(page: Page): Promise { + await page.goto("/wikify/settings/models"); + await expect(page.getByText("OpenRouter model ids per pipeline step.")).toBeVisible(); + return page.evaluate(() => !!(window as any).developer_mode); +} + +test.describe("PR #57 local models toggle", () => { + let original = 0; + + test.beforeEach(async ({ api }) => { + original = Number(await api.getValue(SINGLE, SINGLE, "use_local_models")) || 0; + }); + + test.afterEach(async ({ api }) => { + await api.setValue(SINGLE, SINGLE, { use_local_models: original }); + }); + + test("W57-1 the Local models switch saves use_local_models in developer mode @functional @settings", async ({ + page, + api, + }) => { + const developerMode = await openModels(page); + test.skip(!developerMode, "site is not in developer mode"); + + await api.setValue(SINGLE, SINGLE, { use_local_models: 0 }); + await page.reload(); + await expect(page.getByText("Local models", { exact: true })).toBeVisible(); + + const toggle = page.getByRole("switch").first(); + await toggle.click(); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Settings saved")).toBeVisible(); + expect(Number(await api.getValue(SINGLE, SINGLE, "use_local_models"))).toBe(1); + }); + + test("W57-2 the Local models switch is hidden outside developer mode @negative @settings", async ({ + page, + }) => { + const developerMode = await openModels(page); + test.skip(developerMode, "site is in developer mode"); + + await expect(page.getByText("Agent model")).toBeVisible(); + await expect(page.getByText("Local models", { exact: true })).toHaveCount(0); + }); +}); + +test.describe("PR #60 settings dialog in the url", () => { + test("W60-1 a settings link opens on its tab, tabs update the url and Escape returns to the page @functional @settings", async ({ + page, + }) => { + await page.goto("/wikify/settings/models"); + await expect(page.getByText("OpenRouter model ids per pipeline step.")).toBeVisible(); + + await page.getByRole("tab", { name: "Scoring" }).click(); + await expect(page).toHaveURL(/\/wikify\/settings\/scoring$/); + + await page.keyboard.press("Escape"); + await expect(page).not.toHaveURL(/\/settings\//); + await expect(page.getByRole("tab", { name: "Scoring" })).toHaveCount(0); + }); + + test("W60-2 an unknown settings tab falls back to the first tab @negative @settings", async ({ + page, + }) => { + await page.goto("/wikify/settings/nonsense"); + await expect(page).toHaveURL(/\/wikify\/settings\/openrouter$/); + await expect(page.getByText("OpenRouter model ids per pipeline step.")).toBeHidden(); + }); +});