Release Diff Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { expect, test, type Page, type Response } from "@playwright/test";
|
||||
|
||||
const ORIGIN = "http://127.0.0.1:4173";
|
||||
const APP_PATH = "/deep/nested/diff/";
|
||||
|
||||
function auditPage(page: Page) {
|
||||
const external: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const responses: Response[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
page.on("response", (response) => responses.push(response));
|
||||
void page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== ORIGIN) {
|
||||
external.push(url.href);
|
||||
await route.abort();
|
||||
} else await route.continue();
|
||||
});
|
||||
return { external, errors, responses };
|
||||
}
|
||||
|
||||
async function openApp(page: Page) {
|
||||
await page.goto(APP_PATH);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Diff Tools", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/substantive change row/u)).toBeVisible();
|
||||
}
|
||||
|
||||
test("runs from a nested path in a same-origin module worker", async ({
|
||||
page,
|
||||
}) => {
|
||||
const audit = auditPage(page);
|
||||
await openApp(page);
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Comparison result" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("CRLF", { exact: true }).first()).toBeVisible();
|
||||
|
||||
const worker = audit.responses.find((response) =>
|
||||
/\/deep\/nested\/diff\/assets\/diff\.worker-[\w-]+\.js$/u.test(
|
||||
new URL(response.url()).pathname,
|
||||
),
|
||||
);
|
||||
expect(worker, "the comparator worker was requested").toBeTruthy();
|
||||
expect(new URL(worker!.url()).origin).toBe(ORIGIN);
|
||||
expect(worker!.headers()["content-type"]).toContain("text/javascript");
|
||||
expect(worker!.headers()["cache-control"]).toBe(
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
expect(audit.external).toEqual([]);
|
||||
expect(audit.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("exports exact-decimal JSON Patch without losing the prior result", async ({
|
||||
page,
|
||||
}) => {
|
||||
const audit = auditPage(page);
|
||||
await openApp(page);
|
||||
await page.getByRole("tab", { name: /JSON Semantic/u }).click();
|
||||
await page.getByTestId("left-editor").fill('{"amount":1,"keep":true}');
|
||||
await page
|
||||
.getByTestId("right-editor")
|
||||
.fill('{"amount":9007199254740993123456789,"keep":true}');
|
||||
await page.getByRole("button", { name: "Compare now" }).click();
|
||||
await expect(page.getByText(/1 substantive change row/u)).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Patches" }).click();
|
||||
await expect(page.getByTestId("json-patch")).toContainText(
|
||||
"9007199254740993123456789",
|
||||
);
|
||||
|
||||
await page.getByRole("tab", { name: /XML Namespace-aware/u }).click();
|
||||
await expect(page.getByText(/substantive change row/u)).toBeVisible();
|
||||
const previousVerdict = page.getByText("Different", { exact: true }).first();
|
||||
await expect(previousVerdict).toBeVisible();
|
||||
await page.getByTestId("left-editor").fill("<!DOCTYPE x><x/>");
|
||||
await page.getByRole("button", { name: "Compare now" }).click();
|
||||
await expect(page.getByText("xml.doctype-rejected")).toBeVisible();
|
||||
await expect(previousVerdict).toBeVisible();
|
||||
expect(audit.external).toEqual([]);
|
||||
expect(audit.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("opens local keyed CSV and reports duplicate keys", async ({ page }) => {
|
||||
const audit = auditPage(page);
|
||||
await openApp(page);
|
||||
await page.getByRole("tab", { name: /CSV \/ TSV Keyed rows/u }).click();
|
||||
await page.getByTestId("left-file-input").setInputFiles({
|
||||
name: "duplicates.csv",
|
||||
mimeType: "text/csv",
|
||||
buffer: Buffer.from("id,name\n1,Ada\n1,Grace", "utf8"),
|
||||
});
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "duplicates.csv" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Compare now" }).click();
|
||||
await expect(page.getByText("csv.duplicate-key")).toBeVisible();
|
||||
await expect(page.getByText("Duplicate key", { exact: false })).toBeVisible();
|
||||
expect(audit.external).toEqual([]);
|
||||
expect(audit.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("serves the release identity and hardened headers", async ({
|
||||
request,
|
||||
}) => {
|
||||
const index = await request.get(APP_PATH);
|
||||
expect(index.ok()).toBe(true);
|
||||
expect(index.headers()["content-security-policy"]).toContain(
|
||||
"default-src 'self'",
|
||||
);
|
||||
expect(index.headers()["content-security-policy"]).toContain(
|
||||
"worker-src 'self' blob:",
|
||||
);
|
||||
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
|
||||
const manifest = await request.get(`${APP_PATH}toolbox-app.json`);
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.diff-tools",
|
||||
version: "0.1.0",
|
||||
entry: "./",
|
||||
privacy: { processing: "local", fileUploads: true, telemetry: false },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Workbench } from "../../src/components/Workbench";
|
||||
|
||||
describe("Diff Tools workbench", () => {
|
||||
it("renders exact newline metadata and both result layouts", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
expect(screen.getByText("Browser-local")).toBeVisible();
|
||||
expect(await screen.findByText(/substantive change row/u)).toBeVisible();
|
||||
expect(screen.getAllByText("CRLF").length).toBeGreaterThan(0);
|
||||
await user.click(screen.getByRole("tab", { name: "Side by side" }));
|
||||
expect(
|
||||
screen.getByRole("table", { name: "Side-by-side differences" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps exact large numbers in the RFC 6902 artifact", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(screen.getByRole("tab", { name: /JSON Semantic/u }));
|
||||
fireEvent.change(screen.getByTestId("left-editor"), {
|
||||
target: { value: '{"n":1}' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("right-editor"), {
|
||||
target: { value: '{"n":9007199254740993123456789}' },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Compare now" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/1 substantive change row/u)).toBeVisible(),
|
||||
);
|
||||
await user.click(screen.getByRole("tab", { name: "Patches" }));
|
||||
expect(
|
||||
(screen.getByTestId("json-patch") as HTMLTextAreaElement).value,
|
||||
).toContain("9007199254740993123456789");
|
||||
});
|
||||
|
||||
it("keeps the last successful XML result visible beside an error", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(screen.getByRole("tab", { name: /XML Namespace-aware/u }));
|
||||
expect(await screen.findByText(/substantive change row/u)).toBeVisible();
|
||||
const verdict = screen.getByText("Different", { exact: true });
|
||||
fireEvent.change(screen.getByTestId("left-editor"), {
|
||||
target: { value: "<!DOCTYPE x><x/>" },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Compare now" }));
|
||||
expect(await screen.findByText("xml.doctype-rejected")).toBeVisible();
|
||||
expect(verdict).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compareInputs } from "../../src/core/compare";
|
||||
import { DEFAULT_OPTIONS, type CompareRequest } from "../../src/core/types";
|
||||
|
||||
function request(
|
||||
mode: CompareRequest["mode"],
|
||||
left: string,
|
||||
right: string,
|
||||
overrides: Partial<CompareRequest["options"]> = {},
|
||||
): CompareRequest {
|
||||
return {
|
||||
mode,
|
||||
left,
|
||||
right,
|
||||
options: {
|
||||
text: { ...DEFAULT_OPTIONS.text, ...overrides.text },
|
||||
json: { ...DEFAULT_OPTIONS.json, ...overrides.json },
|
||||
xml: { ...DEFAULT_OPTIONS.xml, ...overrides.xml },
|
||||
csv: { ...DEFAULT_OPTIONS.csv, ...overrides.csv },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("text comparison", () => {
|
||||
it("preserves exact final-newline and line-ending state", () => {
|
||||
const result = compareInputs(request("text", "one\r\ntwo", "one\ntwo\n"));
|
||||
expect(result.left.newlines).toEqual({
|
||||
lf: 0,
|
||||
crlf: 1,
|
||||
cr: 0,
|
||||
final: "none",
|
||||
});
|
||||
expect(result.right.newlines).toEqual({
|
||||
lf: 2,
|
||||
crlf: 0,
|
||||
cr: 0,
|
||||
final: "lf",
|
||||
});
|
||||
expect(result.exactlyEqual).toBe(false);
|
||||
expect(result.unifiedPatch).toContain("\\ No newline at end of file");
|
||||
});
|
||||
|
||||
it("shows source differences that compare equal after normalization", () => {
|
||||
const result = compareInputs(
|
||||
request("text", "Cafe\u0301\r\n", "CAFÉ\n", {
|
||||
text: {
|
||||
...DEFAULT_OPTIONS.text,
|
||||
unicodeNormalization: "NFC",
|
||||
ignoreCase: true,
|
||||
ignoreLineEndingStyle: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result.semanticallyEqual).toBe(true);
|
||||
expect(result.rows).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ kind: "normalized" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["line", "word", "code-point", "grapheme"] as const)(
|
||||
"supports %s granularity",
|
||||
(granularity) => {
|
||||
const result = compareInputs(
|
||||
request("text", "A 👨👩👧", "A 👨👩👦", {
|
||||
text: { ...DEFAULT_OPTIONS.text, granularity },
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
result.stats.modified + result.stats.added + result.stats.removed,
|
||||
).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["line", "word", "code-point", "grapheme"] as const)(
|
||||
"normalizes line-ending style at %s granularity",
|
||||
(granularity) => {
|
||||
const result = compareInputs(
|
||||
request("text", "A\r\nB", "A\nB", {
|
||||
text: {
|
||||
...DEFAULT_OPTIONS.text,
|
||||
granularity,
|
||||
ignoreLineEndingStyle: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result.semanticallyEqual).toBe(true);
|
||||
expect(result.stats.normalized).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("semantic JSON", () => {
|
||||
it("compares object order and exact decimal value semantically", () => {
|
||||
const result = compareInputs(
|
||||
request(
|
||||
"json",
|
||||
'{"amount":1.00,"large":9007199254740993123456789,"name":"Ada"}',
|
||||
'{"name":"Ada","large":9007199254740993123456789,"amount":1e0}',
|
||||
),
|
||||
);
|
||||
expect(result.semanticallyEqual).toBe(true);
|
||||
expect(result.exactlyEqual).toBe(false);
|
||||
expect(result.stats.normalized).toBeGreaterThanOrEqual(2);
|
||||
expect(result.jsonPatch).toBe("[]\n");
|
||||
});
|
||||
|
||||
it("reports common-member reordering alongside additions", () => {
|
||||
const result = compareInputs(
|
||||
request("json", '{"a":1,"b":2}', '{"b":2,"a":1,"c":3}'),
|
||||
);
|
||||
expect(result.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "normalized", path: "" }),
|
||||
expect.objectContaining({ kind: "added", path: "/c" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits an exact RFC 6902 patch", () => {
|
||||
const result = compareInputs(
|
||||
request(
|
||||
"json",
|
||||
'{"amount":1,"items":["a","b"]}',
|
||||
'{"amount":9007199254740993123456789,"items":["a","c","d"]}',
|
||||
),
|
||||
);
|
||||
expect(result.jsonPatch).toContain("9007199254740993123456789");
|
||||
expect(result.jsonPatch).toContain('"op": "replace"');
|
||||
expect(result.jsonPatch).toContain('"op": "add"');
|
||||
});
|
||||
|
||||
it("rejects duplicates and prototype-affecting keys", () => {
|
||||
expect(() =>
|
||||
compareInputs(request("json", '{"a":1,"a":2}', '{"a":2}')),
|
||||
).toThrow(/Duplicate JSON key/iu);
|
||||
expect(() =>
|
||||
compareInputs(request("json", '{"__proto__":1}', "{}")),
|
||||
).toThrow(/prototype-affecting/iu);
|
||||
});
|
||||
});
|
||||
|
||||
describe("namespace-aware XML", () => {
|
||||
it("ignores prefixes and attribute order while keeping them visible", () => {
|
||||
const result = compareInputs(
|
||||
request(
|
||||
"xml",
|
||||
'<a:root xmlns:a="urn:x" id="1" role="test"><![CDATA[ hello ]]></a:root>',
|
||||
'<b:root role="test" id="1" xmlns:b="urn:x"> hello </b:root>',
|
||||
),
|
||||
);
|
||||
expect(result.semanticallyEqual).toBe(true);
|
||||
expect(result.stats.normalized).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects DOCTYPE and XInclude before comparison", () => {
|
||||
expect(() =>
|
||||
compareInputs(request("xml", "<!DOCTYPE x><x/>", "<x/>")),
|
||||
).toThrow(/rejected/iu);
|
||||
expect(() =>
|
||||
compareInputs(
|
||||
request(
|
||||
"xml",
|
||||
'<x xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="x"/></x>',
|
||||
"<x/>",
|
||||
),
|
||||
),
|
||||
).toThrow(/XInclude/iu);
|
||||
});
|
||||
|
||||
it("rejects excessive XML nesting before DOM construction", () => {
|
||||
const deeplyNested = `${"<x>".repeat(129)}${"</x>".repeat(129)}`;
|
||||
expect(() => compareInputs(request("xml", deeplyNested, "<x/>"))).toThrow(
|
||||
/XML depth/iu,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats attribute order as substantive when normalization is disabled", () => {
|
||||
const result = compareInputs(
|
||||
request("xml", '<x a="1" b="2"/>', '<x b="2" a="1"/>', {
|
||||
xml: { ...DEFAULT_OPTIONS.xml, ignoreAttributeOrder: false },
|
||||
}),
|
||||
);
|
||||
expect(result.semanticallyEqual).toBe(false);
|
||||
expect(result.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "modified", path: "/x[1]/@*" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyed CSV", () => {
|
||||
it("matches rows by key and compares fields as strings", () => {
|
||||
const result = compareInputs(
|
||||
request(
|
||||
"csv",
|
||||
"id,name,value\n2,Grace,001\n1,Ada,10",
|
||||
"value,id,name\n10,1,Ada\n002,2,Grace",
|
||||
{ csv: { ...DEFAULT_OPTIONS.csv, keyColumns: "id" } },
|
||||
),
|
||||
);
|
||||
expect(result.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "normalized", path: "$columns" }),
|
||||
expect.objectContaining({
|
||||
kind: "modified",
|
||||
left: "001",
|
||||
right: "002",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate keys with a row diagnostic", () => {
|
||||
expect(() =>
|
||||
compareInputs(
|
||||
request("csv", "id,name\n1,A\n1,B", "id,name\n1,A", {
|
||||
csv: { ...DEFAULT_OPTIONS.csv, keyColumns: "id" },
|
||||
}),
|
||||
),
|
||||
).toThrow(/Duplicate key/iu);
|
||||
});
|
||||
|
||||
it("keeps common row and column reordering visible when items are added", () => {
|
||||
const result = compareInputs(
|
||||
request(
|
||||
"csv",
|
||||
"id,a,b\n1,A,B\n2,C,D",
|
||||
"b,id,a,extra\nD,2,C,x\nB,1,A,y\nZ,3,E,z",
|
||||
{ csv: { ...DEFAULT_OPTIONS.csv, keyColumns: "id" } },
|
||||
),
|
||||
);
|
||||
expect(result.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "normalized", path: "$columns" }),
|
||||
expect.objectContaining({ kind: "normalized", path: "$rows" }),
|
||||
expect.objectContaining({ kind: "added", path: "$columns/extra" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("portable artifacts", () => {
|
||||
it("produces a schema-labelled JSON report without library HTML", () => {
|
||||
const result = compareInputs(request("text", "before\n", "after\n"));
|
||||
expect(JSON.parse(result.report)).toMatchObject({
|
||||
schema: "de.add-ideas.diff-tools.report.v1",
|
||||
generatedLocally: true,
|
||||
});
|
||||
expect(result.report).not.toContain("<ins");
|
||||
expect(result.unifiedPatch).toContain("--- before.txt");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user