Release Contact Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const ORIGIN = "http://127.0.0.1:4173";
|
||||
async function localOnly(page: Page) {
|
||||
const external: string[] = [];
|
||||
await 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;
|
||||
}
|
||||
|
||||
test("runs from a nested path without external requests", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/contact/");
|
||||
await expect(
|
||||
page.getByRole("banner").getByRole("heading", { name: "Contact Tools" }),
|
||||
).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves contacts on parse failure and generates QR locally", async ({
|
||||
page,
|
||||
}) => {
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/contact/");
|
||||
await expect(page.getByRole("heading", { name: "2 contacts" })).toBeVisible();
|
||||
await page.getByLabel("vCard source").fill("not a card");
|
||||
await page.getByRole("button", { name: "Parse safely" }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("No complete");
|
||||
await page.getByRole("tab", { name: "Contact QR" }).click();
|
||||
await page.getByRole("button", { name: "Generate local QR preview" }).click();
|
||||
await expect(page.getByRole("img", { name: /QR code for/u })).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
|
||||
test("serves release identity and hardened headers", async ({ request }) => {
|
||||
const index = await request.get("/deep/nested/contact/");
|
||||
expect(index.ok()).toBe(true);
|
||||
expect(index.headers()["content-security-policy"]).toContain(
|
||||
"connect-src 'self'",
|
||||
);
|
||||
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
|
||||
const manifest = await request.get("/deep/nested/contact/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.contact-tools",
|
||||
version: "0.1.0",
|
||||
entry: "./",
|
||||
privacy: { processing: "local", telemetry: false },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../../src/App";
|
||||
|
||||
describe("Contact Tools", () => {
|
||||
it("renders the local workbench and exposes duplicate reasons", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("Not found", { status: 404 })),
|
||||
);
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Contact Tools" }),
|
||||
).toBeVisible();
|
||||
await userEvent.click(
|
||||
await screen.findByRole("tab", { name: "Duplicates" }),
|
||||
);
|
||||
expect(screen.getByText(/same normalized email/u)).toBeVisible();
|
||||
expect(screen.getByText("Browser-local")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
analyzeDuplicates,
|
||||
contactsFromCsv,
|
||||
contactsToCsv,
|
||||
findDuplicates,
|
||||
inspectCsv,
|
||||
mergeContacts,
|
||||
parseVCards,
|
||||
serializeVCards,
|
||||
} from "../../src/contact/model";
|
||||
import { contactQrPayload } from "../../src/contact/qr";
|
||||
|
||||
const twoCards = `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\nEMAIL;TYPE=HOME,PREF:ada@example.test\r\nNOTE:one\r\n two\r\nEND:VCARD\r\nBEGIN:VCARD\r\nVERSION:4.0\r\nFN:Ada L. Lovelace\r\nN:Lovelace;Ada;L.;;\r\nEMAIL;PREF=1:ADA@example.test\r\nEND:VCARD\r\n`;
|
||||
|
||||
describe("vCard model", () => {
|
||||
it("unfolds and parses vCard 3 and 4 values", () => {
|
||||
const parsed = parseVCards(twoCards);
|
||||
expect(parsed.contacts).toHaveLength(2);
|
||||
expect(parsed.contacts[0]).toMatchObject({
|
||||
version: "3.0",
|
||||
familyName: "Lovelace",
|
||||
givenName: "Ada",
|
||||
note: "onetwo",
|
||||
});
|
||||
expect(parsed.contacts[0]?.emails[0]).toMatchObject({
|
||||
value: "ada@example.test",
|
||||
preferred: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes canonical CRLF cards and round trips supported data", () => {
|
||||
const contacts = parseVCards(twoCards).contacts;
|
||||
const output = serializeVCards(contacts, "4.0");
|
||||
expect(output).toContain("VERSION:4.0\r\n");
|
||||
expect(output).not.toContain("TEL");
|
||||
expect(parseVCards(output).contacts[0]?.formattedName).toBe("Ada Lovelace");
|
||||
});
|
||||
|
||||
it("rejects incomplete and oversized inputs", () => {
|
||||
expect(() => parseVCards("BEGIN:VCARD\nFN:No end")).toThrow(/No complete/u);
|
||||
expect(() => parseVCards("x".repeat(2 * 1024 * 1024 + 1))).toThrow(
|
||||
/2 MiB/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("maps quoted CSV and exports it deterministically", () => {
|
||||
const csv =
|
||||
'Full Name,Email,Notes\r\n"Doe, Jane",jane@example.test,"line 1\nline 2"\r\n';
|
||||
const inspected = inspectCsv(csv);
|
||||
expect(inspected.inferred).toMatchObject({
|
||||
formattedName: "Full Name",
|
||||
email: "Email",
|
||||
note: "Notes",
|
||||
});
|
||||
const contacts = contactsFromCsv(csv, inspected.inferred);
|
||||
expect(contacts[0]?.formattedName).toBe("Doe, Jane");
|
||||
expect(contactsToCsv(contacts)).toContain('"Doe, Jane"');
|
||||
});
|
||||
|
||||
it("finds exact normalized duplicates and reports merge conflicts", () => {
|
||||
const contacts = parseVCards(twoCards).contacts;
|
||||
const candidates = findDuplicates(contacts);
|
||||
expect(candidates[0]).toMatchObject({ score: 70 });
|
||||
const merged = mergeContacts(contacts[0]!, contacts[1]!);
|
||||
expect(merged.merged.emails).toHaveLength(1);
|
||||
expect(merged.conflicts.join(" ")).toMatch(/Name/u);
|
||||
});
|
||||
|
||||
it("folds long Unicode properties in linear-size UTF-8 chunks", () => {
|
||||
const contact = parseVCards(twoCards).contacts[0]!;
|
||||
const output = serializeVCards([{ ...contact, note: "é".repeat(10_000) }]);
|
||||
for (const line of output.split("\r\n").filter(Boolean))
|
||||
expect(new TextEncoder().encode(line).length).toBeLessThanOrEqual(75);
|
||||
});
|
||||
|
||||
it("bounds pathological duplicate groups and reports truncation", () => {
|
||||
const contact = parseVCards(twoCards).contacts[0]!;
|
||||
const report = analyzeDuplicates(
|
||||
Array.from({ length: 2_000 }, (_value, index) => ({
|
||||
...contact,
|
||||
id: `same-${index}`,
|
||||
})),
|
||||
);
|
||||
expect(report).toMatchObject({
|
||||
truncated: true,
|
||||
evaluatedPairs: 25_000,
|
||||
candidateLimit: 10_000,
|
||||
});
|
||||
expect(report.candidates.length).toBeLessThanOrEqual(10_000);
|
||||
});
|
||||
|
||||
it("does not collapse punctuation-distinct emails or URLs during merge", () => {
|
||||
const left = parseVCards(twoCards).contacts[0]!;
|
||||
const right = {
|
||||
...parseVCards(twoCards).contacts[1]!,
|
||||
emails: [
|
||||
{ value: "adalovelace@example.test", types: [], preferred: false },
|
||||
],
|
||||
urls: [
|
||||
{ value: "https://example.test/a-b", types: [], preferred: false },
|
||||
],
|
||||
};
|
||||
const merged = mergeContacts(
|
||||
{
|
||||
...left,
|
||||
emails: [
|
||||
{ value: "ada.lovelace@example.test", types: [], preferred: true },
|
||||
],
|
||||
urls: [
|
||||
{ value: "https://example.test/ab", types: [], preferred: true },
|
||||
],
|
||||
},
|
||||
right,
|
||||
).merged;
|
||||
expect(merged.emails).toHaveLength(2);
|
||||
expect(merged.urls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("mitigates spreadsheet formulas in CSV export", () => {
|
||||
const contact = parseVCards(twoCards).contacts[0]!;
|
||||
const output = contactsToCsv([
|
||||
{ ...contact, formattedName: '=HYPERLINK("bad")' },
|
||||
]);
|
||||
expect(output.split("\r\n")[1]).toContain("'=HYPERLINK");
|
||||
});
|
||||
|
||||
it("keeps QR payloads below conservative version-40 M capacity", () => {
|
||||
const contact = parseVCards(twoCards).contacts[0]!;
|
||||
expect(() =>
|
||||
contactQrPayload({ ...contact, note: "x".repeat(2_300) }),
|
||||
).toThrow(/too large/u);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user