@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expect, test, type Download, type Page } from "@playwright/test";
|
||||
import {
|
||||
createMinimalOdp,
|
||||
createMinimalOds,
|
||||
@@ -25,9 +25,31 @@ async function find(page: Page, value: string) {
|
||||
await expect(search.getByText("1 of 1", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function downloadedBytes(download: Download): Promise<Uint8Array> {
|
||||
const stream = await download.createReadStream();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for await (const chunk of stream) {
|
||||
const bytes =
|
||||
typeof chunk === "string"
|
||||
? new TextEncoder().encode(chunk)
|
||||
: new Uint8Array(chunk as ArrayBufferLike);
|
||||
chunks.push(bytes);
|
||||
total += bytes.byteLength;
|
||||
}
|
||||
const result = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
test("renders and searches a project-authored ODT in an isolated worker", async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = createMinimalOdt();
|
||||
const externalHosts = new Set<string>();
|
||||
const errors: string[] = [];
|
||||
const workerScripts: URL[] = [];
|
||||
@@ -45,7 +67,7 @@ test("renders and searches a project-authored ODT in an isolated worker", async
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
|
||||
await openFixture(page, createMinimalOdt());
|
||||
await openFixture(page, fixture);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "OpenDocument Text Fixture" }),
|
||||
).toBeVisible();
|
||||
@@ -59,6 +81,11 @@ test("renders and searches a project-authored ODT in an isolated worker", async
|
||||
await page.getByRole("button", { name: "Zoom in" }).click();
|
||||
await expect(page.getByText("110%", { exact: true })).toBeVisible();
|
||||
|
||||
const exactCopy = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Save exact copy" }).click();
|
||||
const exactBytes = await downloadedBytes(await exactCopy);
|
||||
expect([...exactBytes]).toEqual([...fixture.buffer]);
|
||||
|
||||
expect(externalHosts).toEqual(new Set());
|
||||
expect(errors).toEqual([]);
|
||||
expect(workerScripts.length).toBeGreaterThan(0);
|
||||
@@ -81,6 +108,12 @@ test("renders, searches and switches sheets in a project-authored ODS", async ({
|
||||
await page.getByRole("button", { name: "Next sheet" }).click();
|
||||
await expect(page.getByText("sheet 2 of 2", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("grid")).toContainText("Second sheet content");
|
||||
const csvDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Export sheet CSV" }).click();
|
||||
const csv = new TextDecoder().decode(
|
||||
await downloadedBytes(await csvDownload),
|
||||
);
|
||||
expect(csv).toContain("Second sheet content");
|
||||
});
|
||||
|
||||
test("renders, searches and navigates a project-authored ODP", async ({
|
||||
|
||||
@@ -24,10 +24,10 @@ test("publishes valid relocatable metadata", async ({ request }) => {
|
||||
await expect(manifestResponse.json()).resolves.toMatchObject({
|
||||
schemaVersion: 1,
|
||||
id: "de.add-ideas.office-tools",
|
||||
version: "0.1.0",
|
||||
version: "0.2.0",
|
||||
entry: "./",
|
||||
icon: "./favicon.svg",
|
||||
privacy: { processing: "local", fileUploads: false, telemetry: false },
|
||||
privacy: { processing: "local", fileUploads: true, telemetry: false },
|
||||
});
|
||||
|
||||
const webManifest = await request.get(`${entry}manifest.webmanifest`);
|
||||
|
||||
@@ -139,8 +139,9 @@ test("fails closed with an inline error for a malformed OOXML package", async ({
|
||||
const alert = page.getByRole("alert");
|
||||
await expect(alert).toBeVisible({ timeout: 90_000 });
|
||||
await expect(alert).not.toBeEmpty();
|
||||
await expect(page.locator(".file-heading")).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator(".file-heading__state .state-dot--error"),
|
||||
page.getByRole("heading", { name: "Choose or drop a file" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/office/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createStyleResolver,
|
||||
safeOdfTransform,
|
||||
} from "../../src/components/odf-viewer-model";
|
||||
|
||||
describe("OpenDocument fidelity adapters", () => {
|
||||
it("inherits default and named styles without exposing raw CSS", () => {
|
||||
const resolve = createStyleResolver([
|
||||
{
|
||||
name: "__default__:paragraph",
|
||||
isDefault: true,
|
||||
family: "paragraph",
|
||||
properties: { "paragraph-properties.fo:color": "#123456" },
|
||||
},
|
||||
{
|
||||
name: "Body",
|
||||
family: "paragraph",
|
||||
properties: { "paragraph-properties.fo:text-align": "justify" },
|
||||
},
|
||||
]);
|
||||
expect(resolve(undefined, "paragraph")).toMatchObject({ color: "#123456" });
|
||||
expect(resolve("Body", "paragraph")).toMatchObject({
|
||||
color: "#123456",
|
||||
textAlign: "justify",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts only a bounded inert drawing-transform subset", () => {
|
||||
expect(safeOdfTransform("translate(2cm 3cm) rotate(0.5) scale(2)")).toBe(
|
||||
"translate(2cm, 3cm) rotate(0.5rad) scale(2)",
|
||||
);
|
||||
expect(safeOdfTransform("url(https://example.test)")).toBeUndefined();
|
||||
expect(safeOdfTransform("matrix(1 0 0 1 2 3)")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -23,15 +23,33 @@ describe("UploadWorkbench", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UploadWorkbench />);
|
||||
const input = screen.getByLabelText("Open office file");
|
||||
const file = new File(["local fixture"], "report.docx", {
|
||||
const bytes = new Uint8Array(13);
|
||||
bytes.set([0x50, 0x4b, 0x03, 0x04]);
|
||||
const file = new File([bytes], "report.docx", {
|
||||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
});
|
||||
|
||||
await user.upload(input, file);
|
||||
|
||||
const heading = screen.getByRole("region", { name: "report.docx" });
|
||||
const heading = await screen.findByRole("region", { name: "report.docx" });
|
||||
expect(heading).toHaveTextContent("report.docx");
|
||||
expect(heading).toHaveTextContent("13 B");
|
||||
expect(screen.getByLabelText("Test OOXML viewer")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save exact copy" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("explains a renamed legacy compound-binary file", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UploadWorkbench />);
|
||||
const file = new File(
|
||||
[Uint8Array.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])],
|
||||
"renamed.docx",
|
||||
);
|
||||
await user.upload(screen.getByLabelText("Open office file"), file);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"legacy OLE Compound File",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sheetToCsv } from "../../src/office/export";
|
||||
import { parseOds } from "../../src/office/odf";
|
||||
import { makeOdf, odsContent } from "./odf/fixtures";
|
||||
|
||||
describe("bounded OpenDocument exports", () => {
|
||||
it("exports cached sheet displays with repeats and CSV quoting", () => {
|
||||
const document = parseOds(
|
||||
makeOdf(
|
||||
"ods",
|
||||
odsContent(`<table:table table:name="Data">
|
||||
<table:table-row table:number-rows-repeated="2">
|
||||
<table:table-cell office:value-type="string"><text:p>A, B</text:p></table:table-cell>
|
||||
<table:table-cell table:number-columns-repeated="2" office:value-type="float" office:value="42"><text:p>42</text:p></table:table-cell>
|
||||
</table:table-row>
|
||||
</table:table>`),
|
||||
),
|
||||
);
|
||||
expect(sheetToCsv(document.sheets[0]!)).toBe(
|
||||
'"A, B",42,42\r\n"A, B",42,42',
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses unbounded expanded CSV rows", () => {
|
||||
const document = parseOds(
|
||||
makeOdf(
|
||||
"ods",
|
||||
odsContent(`<table:table table:name="Large">
|
||||
<table:table-row table:number-rows-repeated="50001">
|
||||
<table:table-cell office:value-type="string"><text:p>x</text:p></table:table-cell>
|
||||
</table:table-row>
|
||||
</table:table>`),
|
||||
),
|
||||
);
|
||||
expect(() => sheetToCsv(document.sheets[0]!)).toThrow(/50,000 rows/u);
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,15 @@ import {
|
||||
familyForFormat,
|
||||
formatBytes,
|
||||
formatLabel,
|
||||
validateOfficePackagePrefix,
|
||||
} from "../../src/office/formats";
|
||||
|
||||
describe("office format handling", () => {
|
||||
it.each([
|
||||
["REPORT.DOCX", "docx"],
|
||||
["REPORT.DOCM", "docx"],
|
||||
["template.xltx", "xlsx"],
|
||||
["show.ppsm", "pptx"],
|
||||
["notes.odt", "odt"],
|
||||
["budget.xlsx", "xlsx"],
|
||||
["budget.ODS", "ods"],
|
||||
@@ -21,6 +25,24 @@ describe("office format handling", () => {
|
||||
expect(detectOfficeFormat({ name, size: 42 })).toBe(expected);
|
||||
});
|
||||
|
||||
it("rejects renamed compound-binary, RTF and non-package signatures", () => {
|
||||
expect(() =>
|
||||
validateOfficePackagePrefix(
|
||||
Uint8Array.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]),
|
||||
"docx",
|
||||
),
|
||||
).toThrow(/OLE Compound File/u);
|
||||
expect(() =>
|
||||
validateOfficePackagePrefix(new TextEncoder().encode("{\\rtf1"), "docx"),
|
||||
).toThrow(/Rich Text Format/u);
|
||||
expect(() =>
|
||||
validateOfficePackagePrefix(new TextEncoder().encode("not zip!"), "xlsx"),
|
||||
).toThrow(/ZIP-based/u);
|
||||
expect(() =>
|
||||
validateOfficePackagePrefix(Uint8Array.from([0x50, 0x4b, 3, 4]), "pptx"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty, oversized, unsupported and legacy inputs explicitly", () => {
|
||||
const cases = [
|
||||
[{ name: "empty.docx", size: 0 }, "empty-file"],
|
||||
|
||||
@@ -254,4 +254,54 @@ describe("OpenDocument normalized models", () => {
|
||||
expect(document.format).toBe("odt");
|
||||
expect(document.warnings).toContain("Package has no META-INF/manifest.xml");
|
||||
});
|
||||
|
||||
it("retains page geometry, default styles, comments and spreadsheet column metadata", () => {
|
||||
const styles = `<?xml version="1.0"?><office:document-styles ${XMLNS}>
|
||||
<office:styles><style:default-style style:family="paragraph"><style:text-properties fo:color="#123456"/></style:default-style></office:styles>
|
||||
<office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm"/></style:page-layout></office:automatic-styles>
|
||||
</office:document-styles>`;
|
||||
const odt = parseOdt(
|
||||
makeOdf(
|
||||
"odt",
|
||||
odtContent(
|
||||
`<text:p>Reviewed<office:annotation office:name="c1"><dc:creator>Ada</dc:creator><dc:date>2026-09-01</dc:date><text:p>Looks good</text:p></office:annotation></text:p>`,
|
||||
),
|
||||
{ styles },
|
||||
),
|
||||
);
|
||||
expect(odt.pageWidth).toMatchObject({ value: 21, unit: "cm" });
|
||||
expect(odt.styles).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ isDefault: true, family: "paragraph" }),
|
||||
]),
|
||||
);
|
||||
expect(odt.annotations[0]).toMatchObject({
|
||||
id: "c1",
|
||||
creator: "Ada",
|
||||
createdAt: "2026-09-01",
|
||||
blocks: [expect.objectContaining({ text: "Looks good" })],
|
||||
});
|
||||
|
||||
const ods = parseOds(
|
||||
makeOdf(
|
||||
"ods",
|
||||
odsContent(`<table:table table:name="Columns" table:visibility="collapse">
|
||||
<table:table-column table:number-columns-repeated="3" table:style-name="Wide" table:default-cell-style-name="Money"/>
|
||||
<table:table-row table:default-cell-style-name="RowDefault"><table:table-cell office:value-type="string"><text:p>x</text:p></table:table-cell></table:table-row>
|
||||
</table:table>`),
|
||||
),
|
||||
);
|
||||
expect(ods.sheets[0]).toMatchObject({
|
||||
visibility: "collapse",
|
||||
columns: [
|
||||
{
|
||||
index: 0,
|
||||
repeat: 3,
|
||||
styleName: "Wide",
|
||||
defaultCellStyleName: "Money",
|
||||
},
|
||||
],
|
||||
rows: [expect.objectContaining({ defaultCellStyleName: "RowDefault" })],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("Toolbox manifest", () => {
|
||||
});
|
||||
expect(manifest.privacy).toMatchObject({
|
||||
processing: "local",
|
||||
fileUploads: false,
|
||||
fileUploads: true,
|
||||
telemetry: false,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user