Merge remote-tracking branch 'origin/main' into codex/issue-execution-ledger

This commit is contained in:
Amruth Pillai
2026-09-06 03:56:40 +02:00
7 changed files with 1117 additions and 22 deletions
+1
View File
@@ -58,6 +58,7 @@
"@tanstack/react-router": "^1.170.32",
"@tiptap/extension-color": "^3.31.2",
"@tiptap/extension-highlight": "^3.31.2",
"@tiptap/extension-table": "^3.31.2",
"@tiptap/extension-text-align": "^3.31.2",
"@tiptap/extension-text-style": "^3.31.2",
"@tiptap/pm": "^3.31.2",
@@ -0,0 +1,346 @@
// @vitest-environment happy-dom
import type { Editor, JSONContent } from "@tiptap/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { useState } from "react";
import { PromptDialogProvider } from "@/hooks/use-prompt";
import { RichInput } from "./rich-input";
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
const inlineTable = `<table style="width: 300pt; border-collapse: collapse"><tbody><tr><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Alpha</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Beta</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Gamma</td></tr><tr><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Delta</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Epsilon</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Zeta</td></tr></tbody></table>`;
const complexTable = `<table style="width: 300pt; border-collapse: collapse"><tbody><tr><th colspan="2" rowspan="2" style="width: 200pt; border: 2pt dashed #123456; padding: 3pt"><p><strong>Lead</strong> cell</p><p><em>Second</em> paragraph</p></th><th style="width: 100pt">Side</th></tr><tr><td style="border-left: 1pt solid rgb(1, 2, 3)"><p>Tail</p></td></tr></tbody></table>`;
const semanticTable = `<table style="width: 300pt; border-spacing: 0"><tbody><tr style="height: 24pt"><td colspan="2" rowspan="1" colwidth="100,200" align="right" style="padding: 4pt; text-align: right"><p data-indent="2" style="margin-inline-start: 48px; text-align: justify"><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow" class="link"><span style="color: #123456">Inside</span></a> <mark data-color="#ffff00" style="background-color: #ffff00; color: inherit">Mark</mark> <mark data-color="#000000" style="background-color: #000000; color: inherit; color: #ffffff">Dark</mark></p></td><td><ol start="2"><li style="text-align: center">Two</li></ol></td></tr></tbody></table>`;
const unsupportedTable = `<table border="1" style="width: 300pt"><tbody><tr><td>Original</td></tr></tbody></table>`;
const unsupportedTables = [
["legacy table attributes", unsupportedTable],
["truncated table marker", "<table"],
["truncated table opening tag", '<table style="width: 100pt"'],
["truncated table closing tag", "</table"],
["unclosed table", "<table><tbody><tr><td>Open"],
["truncated table after a complete table", "<table><tbody><tr><td>Complete</td></tr></tbody></table><table"],
[
"unrepresented descendant elements and attributes",
`<table><tbody><tr><td><section aria-label="keep">Inside</section></td></tr></tbody></table>`,
],
[
"multiple table bodies",
"<table><tbody><tr><td>First</td></tr></tbody><tbody><tr><td>Second</td></tr></tbody></table>",
],
["empty table body", "<table><tbody></tbody></table>"],
["non-row table body child", "<table><tbody><script></script><tr><td>Inside</td></tr></tbody></table>"],
["non-cell table row child", "<table><tbody><tr><script></script><td>Inside</td></tr></tbody></table>"],
["mismatched row widths", "<table><tbody><tr><td>One</td></tr><tr><td>Two</td><td>Three</td></tr></tbody></table>"],
[
"non-rectangular column span",
'<table><tbody><tr><td colspan="2">Wide</td></tr><tr><td colspan="3">Wider</td></tr></tbody></table>',
],
["row span beyond table bounds", '<table><tbody><tr><td rowspan="2">Inside</td></tr></tbody></table>'],
[
"row span collision",
'<table><tbody><tr><td rowspan="2">Tall</td><td>Side</td></tr><tr><td colspan="2">Overlap</td></tr></tbody></table>',
],
["browser-repaired malformed markup", "<table><tbody><tr><td>Broken</tr></tbody></table>"],
[
"unrepresented attributes on supported descendants",
`<table><tbody><tr><td><p data-keep="yes">Inside</p></td></tr></tbody></table>`,
],
["invalid table styles", '<table style="width: banana"><tbody><tr><td>Inside</td></tr></tbody></table>'],
["invalid row styles", '<table><tbody><tr style="height: banana"><td>Inside</td></tr></tbody></table>'],
["invalid cell alignment", '<table><tbody><tr><td align="justify">Inside</td></tr></tbody></table>'],
[
"invalid cell style alignment",
'<table><tbody><tr><td style="text-align: justify">Inside</td></tr></tbody></table>',
],
["invalid column widths", '<table><tbody><tr><td colwidth="100,bogus">Inside</td></tr></tbody></table>'],
["mismatched column widths", '<table><tbody><tr><td colspan="2" colwidth="100">Inside</td></tr></tbody></table>'],
["invalid column span", '<table><tbody><tr><td colspan="0">Inside</td></tr></tbody></table>'],
["invalid row span", '<table><tbody><tr><td rowspan="-1">Inside</td></tr></tbody></table>'],
[
"invalid paragraph alignment",
'<table><tbody><tr><td><p style="text-align: sideways">Inside</p></td></tr></tbody></table>',
],
["invalid indent level", '<table><tbody><tr><td><p data-indent="9">Inside</p></td></tr></tbody></table>'],
[
"mismatched indent margin",
'<table><tbody><tr><td><p data-indent="2" style="margin-inline-start: 12px">Inside</p></td></tr></tbody></table>',
],
[
"physical indent margin",
'<table><tbody><tr><td><p data-indent="2" style="margin-left: 48px">Inside</p></td></tr></tbody></table>',
],
[
"invalid ordered-list start",
'<table><tbody><tr><td><ol start="first"><li>Inside</li></ol></td></tr></tbody></table>',
],
["empty blockquote", "<table><tbody><tr><td><blockquote></blockquote></td></tr></tbody></table>"],
["whitespace-only blockquote", "<table><tbody><tr><td><blockquote> </blockquote></td></tr></tbody></table>"],
["empty list", "<table><tbody><tr><td><ul></ul></td></tr></tbody></table>"],
[
"anchor without href",
'<table><tbody><tr><td><p><a target="_blank" rel="noopener" class="link">Inside</a></p></td></tr></tbody></table>',
],
[
"unsafe link URI",
'<table><tbody><tr><td><p><a href="javascript:alert(1)">Inside</a></p></td></tr></tbody></table>',
],
[
"unsafe link URI with ignored whitespace",
'<table><tbody><tr><td><p><a href="java\u200Bscript:alert(1)">Inside</a></p></td></tr></tbody></table>',
],
[
"invalid text color",
'<table><tbody><tr><td><p><span style="color: nope">Inside</span></p></td></tr></tbody></table>',
],
[
"invalid highlight color",
'<table><tbody><tr><td><p><mark data-color="nope" style="background-color: nope; color: inherit">Inside</mark></p></td></tr></tbody></table>',
],
[
"mismatched highlight color",
'<table><tbody><tr><td><p><mark data-color="#ffff00" style="background-color: #000000; color: inherit">Inside</mark></p></td></tr></tbody></table>',
],
] as const;
type InputOptions = {
className?: string;
editable?: boolean;
};
async function input(value: string, options: InputOptions = {}) {
let editor: Editor | undefined;
const onChange = vi.fn();
const renderInput = (nextValue: string, nextOptions: InputOptions = options) => (
<I18nProvider i18n={i18n}>
<PromptDialogProvider>
<RichInput
aria-label="Table editor"
value={nextValue}
onChange={onChange}
className={nextOptions.className}
editable={nextOptions.editable}
onCreate={(event) => {
editor = event.editor;
}}
/>
</PromptDialogProvider>
</I18nProvider>
);
const result = render(renderInput(value));
await waitFor(() => expect(editor).toBeDefined());
if (!editor) throw new Error("Editor did not initialize");
return {
editor,
onChange,
rerender: (nextValue: string, nextOptions?: InputOptions) => result.rerender(renderInput(nextValue, nextOptions)),
};
}
async function controlledInput(initialValue: string) {
let editor: Editor | undefined;
const onChange = vi.fn();
function Harness() {
const [value, setValue] = useState(initialValue);
const [editable, setEditable] = useState(true);
const [className, setClassName] = useState("initial");
return (
<I18nProvider i18n={i18n}>
<PromptDialogProvider>
<button type="button" onClick={() => setEditable((current) => !current)}>
Toggle lock
</button>
<button type="button" onClick={() => setClassName("updated")}>
Update prop
</button>
<output data-testid="stored-value">{value}</output>
<RichInput
aria-label="Table editor"
value={value}
onChange={(nextValue) => {
onChange(nextValue);
setValue(nextValue);
}}
className={className}
editable={editable}
onCreate={(event) => {
editor = event.editor;
}}
/>
</PromptDialogProvider>
</I18nProvider>
);
}
render(<Harness />);
await waitFor(() => expect(editor).toBeDefined());
if (!editor) throw new Error("Editor did not initialize");
return { editor, onChange };
}
const jsonText = (node: JSONContent): string => {
if ("text" in node) return node.text ?? "";
return node.content?.map(jsonText).join("") ?? "";
};
const tableMatrix = (editor: Editor): string[][] => {
const table = editor.getJSON().content?.find((node) => node.type === "table");
if (!table || !("content" in table)) return [];
return table.content?.map((row) => ("content" in row ? (row.content?.map(jsonText) ?? []) : [])) ?? [];
};
const textPosition = (editor: Editor, text: string): number => {
let position: number | undefined;
editor.state.doc.descendants((node, nodePosition) => {
const offset = node.text?.indexOf(text) ?? -1;
if (position === undefined && offset >= 0) position = nodePosition + offset;
});
if (position === undefined) throw new Error(`Missing text: ${text}`);
return position;
};
describe("RichInput imported tables (#3196)", () => {
it("parses a supported 2x3 table as structured JSON without emitting on mount", async () => {
const { editor, onChange } = await input(inlineTable);
expect(editor.getJSON().content?.[0]?.type).toBe("table");
expect(tableMatrix(editor)).toEqual([
["Alpha", "Beta", "Gamma"],
["Delta", "Epsilon", "Zeta"],
]);
expect(onChange).not.toHaveBeenCalled();
});
it("edits one named cell and retains structure through undo, redo, and remount", async () => {
const { editor, onChange } = await input(inlineTable);
act(() => {
editor.commands.setTextSelection(textPosition(editor, "Beta") + "Beta".length);
editor.commands.insertContent("!");
});
expect(tableMatrix(editor)).toEqual([
["Alpha", "Beta!", "Gamma"],
["Delta", "Epsilon", "Zeta"],
]);
const edited = editor.getHTML();
expect(onChange).toHaveBeenLastCalledWith(edited);
act(() => {
editor.commands.undo();
});
expect(tableMatrix(editor)[0]).toEqual(["Alpha", "Beta", "Gamma"]);
act(() => {
editor.commands.redo();
editor.commands.setContent(edited, { emitUpdate: false });
});
expect(tableMatrix(editor)).toEqual([
["Alpha", "Beta!", "Gamma"],
["Delta", "Epsilon", "Zeta"],
]);
expect(editor.getHTML()).toBe(edited);
});
it("retains spans, multiple paragraphs, inline marks, styles, and unrelated prop updates", async () => {
const { editor, onChange, rerender } = await input(complexTable);
const table = editor.getJSON().content?.[0];
const firstRow = table && "content" in table ? table.content?.[0] : undefined;
const lead = firstRow && "content" in firstRow ? firstRow.content?.[0] : undefined;
expect(lead).toMatchObject({
type: "tableHeader",
attrs: {
colspan: 2,
rowspan: 2,
style: "width: 200pt; border: 2pt dashed #123456; padding: 3pt",
},
});
expect(lead && "content" in lead ? lead.content : undefined).toHaveLength(2);
if (!lead || !("content" in lead)) throw new Error("Missing lead table cell content");
const firstParagraph = lead.content?.[0];
const secondParagraph = lead.content?.[1];
expect(
firstParagraph && "content" in firstParagraph ? firstParagraph.content?.[0]?.marks?.[0]?.type : undefined,
).toBe("bold");
expect(
secondParagraph && "content" in secondParagraph ? secondParagraph.content?.[0]?.marks?.[0]?.type : undefined,
).toBe("italic");
rerender(complexTable, { className: "unrelated", editable: true });
await waitFor(() => expect(editor.getJSON().content?.[0]?.type).toBe("table"));
expect(editor.getHTML()).toContain('colspan="2"');
expect(editor.getHTML()).toContain('rowspan="2"');
expect(editor.getHTML()).toContain("border: 2pt dashed #123456");
expect(onChange).not.toHaveBeenCalled();
});
it("parses pasted supported table HTML into editable table nodes", async () => {
const { editor } = await input("<p>Before</p>");
act(() => {
editor.commands.selectAll();
editor.view.pasteHTML(inlineTable);
});
expect(tableMatrix(editor)).toEqual([
["Alpha", "Beta", "Gamma"],
["Delta", "Epsilon", "Zeta"],
]);
});
it("keeps every supported table semantic editable", async () => {
const { editor, onChange } = await input(semanticTable);
expect(editor.isEditable).toBe(true);
expect(screen.queryByText(/Original table formatting is preserved/)).not.toBeInTheDocument();
act(() => {
editor.commands.setTextSelection(textPosition(editor, "Inside") + "Inside".length);
editor.commands.insertContent("!");
});
expect(editor.getHTML()).toContain("Inside!");
expect(onChange).toHaveBeenCalledOnce();
});
it("edits a configured anchor while preserving its title", async () => {
const titledLink =
'<table><tbody><tr><td><p><a href="https://example.com" title="Profile">Inside</a></p></td></tr></tbody></table>';
const { editor, onChange } = await input(titledLink);
expect(editor.isEditable).toBe(true);
act(() => {
editor.commands.setTextSelection(textPosition(editor, "Inside") + "Inside".length);
editor.commands.insertContent("!");
});
expect(editor.getHTML()).toContain('href="https://example.com"');
expect(editor.getHTML()).toContain('title="Profile"');
expect(onChange).toHaveBeenLastCalledWith(editor.getHTML());
});
it.each(unsupportedTables)("preserves exact bytes for %s behind an accessible read-only notice", async (_, value) => {
const user = userEvent.setup();
const { editor, onChange } = await controlledInput(value);
const notice = screen.getByText(/Original table formatting is preserved/).closest('[role="status"]');
if (!notice) throw new Error("Missing unsupported-table status notice");
expect(notice).toHaveTextContent("Original table formatting is preserved");
expect(screen.queryByRole("button", { name: "Convert to editable text" })).not.toBeInTheDocument();
expect(editor.isEditable).toBe(false);
expect(screen.getByTestId("stored-value")).toHaveTextContent(value, { normalizeWhitespace: false });
await user.click(editor.view.dom);
await user.keyboard("Changed");
await user.click(screen.getByRole("button", { name: "Update prop" }));
await user.click(screen.getByRole("button", { name: "Toggle lock" }));
await user.click(screen.getByRole("button", { name: "Toggle lock" }));
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByTestId("stored-value")).toHaveTextContent(value, { normalizeWhitespace: false });
});
it("keeps unsupported content read-only without a conversion path when caller marks editor as locked", async () => {
const { editor } = await input(unsupportedTable, { editable: false });
expect(editor.isEditable).toBe(false);
expect(screen.queryByRole("button", { name: "Convert to editable text" })).not.toBeInTheDocument();
});
});
+443 -1
View File
@@ -33,6 +33,7 @@ import {
} from "@phosphor-icons/react";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
import TextAlign from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
import { EditorContent, EditorContext, useEditor, useEditorState } from "@tiptap/react";
@@ -62,6 +63,424 @@ import { defaultHighlightColor, resolveHighlightToolbarState } from "./rich-inpu
const defaultTextColor = "rgba(0, 0, 0, 1)";
const borderStyleProperties = [
"border",
"border-top",
"border-right",
"border-bottom",
"border-left",
"border-width",
"border-style",
"border-color",
"border-top-width",
"border-top-style",
"border-top-color",
"border-right-width",
"border-right-style",
"border-right-color",
"border-bottom-width",
"border-bottom-style",
"border-bottom-color",
"border-left-width",
"border-left-style",
"border-left-color",
] as const;
const textBlockTags = new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6"]);
const inlineTags = new Set(["br", "strong", "b", "em", "i", "u", "s", "strike", "code", "a", "span", "mark"]);
const cellBlockTags = new Set([...textBlockTags, "blockquote", "ul", "ol", "hr"]);
type ValueValidator = (value: string, element: Element) => boolean;
type ElementRule = {
attributes?: ReadonlyMap<string, ValueValidator>;
styles?: ReadonlyMap<string, ValueValidator>;
validate?: (element: Element) => boolean;
};
type StyleDeclaration = {
property: string;
value: string;
};
const cssWideKeywords = new Set(["inherit", "initial", "revert", "revert-layer", "unset"]);
const textAlignments = new Set(["left", "center", "right", "justify"]);
const cellAlignments = new Set(["left", "center", "right"]);
const linkProtocols = new Set(["http", "https", "ftp", "ftps", "mailto", "tel", "callto", "sms", "cid", "xmpp"]);
const readStyleDeclarations = (element: Element): StyleDeclaration[] | null => {
const style = element.getAttribute("style");
if (style === null) return [];
const declarations = style
.split(";")
.map((declaration) => declaration.trim())
.filter(Boolean);
if (declarations.length === 0) return null;
const parsed = declarations.map((declaration) => {
const separator = declaration.indexOf(":");
if (separator <= 0) return null;
const property = declaration.slice(0, separator).trim().toLowerCase();
const value = declaration.slice(separator + 1).trim();
return property && value ? { property, value } : null;
});
return parsed.every((declaration) => declaration !== null) ? parsed : null;
};
const supportsCssValue =
(property: string): ValueValidator =>
(value, element) => {
if (/!important\s*$/i.test(value)) return false;
const probe = element.ownerDocument.createElement("span").style;
probe.setProperty(property, value);
return probe.getPropertyValue(property) !== "";
};
const supportsColor: ValueValidator = (value, element) => supportsCssValue("color")(value, element);
const supportsTextAlign =
(allowed: ReadonlySet<string>): ValueValidator =>
(value) =>
allowed.has(value);
const supportsCanonicalInteger =
(minimum: number, maximum = Number.MAX_SAFE_INTEGER): ValueValidator =>
(value) => {
if (!/^-?\d+$/.test(value)) return false;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum && String(parsed) === value;
};
const supportsBorderSpacing: ValueValidator = (value, element) => {
if (cssWideKeywords.has(value)) return true;
const parts = value.split(/\s+/);
return parts.length <= 2 && parts.every((part) => supportsCssValue("width")(part, element));
};
const supportsVerticalAlign: ValueValidator = (value, element) => {
const keywords = new Set(["baseline", "sub", "super", "text-top", "text-bottom", "middle", "top", "bottom"]);
return cssWideKeywords.has(value) || keywords.has(value) || supportsCssValue("width")(value, element);
};
const supportsLinkHref: ValueValidator = (value) => {
if (
!value ||
value.trim() !== value ||
/[\s\u200B-\u200D\u2060\uFEFF]/u.test(value) ||
Array.from(value).some((character) => character.charCodeAt(0) <= 31 || character.charCodeAt(0) === 127)
)
return false;
const scheme = value.match(/^([a-z][a-z0-9+.-]*):/i)?.[1]?.toLowerCase();
return !scheme || linkProtocols.has(scheme);
};
const cssRules = (properties: readonly string[]) =>
new Map<string, ValueValidator>(properties.map((property) => [property, supportsCssValue(property)]));
const styleValues = (element: Element, property: string) =>
(readStyleDeclarations(element) ?? [])
.filter((declaration) => declaration.property === property)
.map(({ value }) => value);
const normalizedColor = (value: string, element: Element) => {
const probe = element.ownerDocument.createElement("span").style;
probe.color = value;
return probe.color;
};
const validateCell = (element: Element) => {
const colspan = element.getAttribute("colspan");
const colwidth = element.getAttribute("colwidth");
if (colwidth) {
const widths = colwidth.split(",");
const span = colspan ? Number(colspan) : 1;
if (widths.length !== span || !widths.every((width) => supportsCanonicalInteger(1)(width, element))) return false;
}
const align = element.getAttribute("align")?.trim().toLowerCase();
const styleAligns = styleValues(element, "text-align");
return styleAligns.length <= 1 && (!align || styleAligns.length === 0 || align === styleAligns[0]);
};
const validateTextBlock = (element: Element) => {
const indent = element.getAttribute("data-indent");
const margins = styleValues(element, "margin-inline-start");
const alignments = styleValues(element, "text-align");
if (margins.length > 1 || alignments.length > 1) return false;
if (!indent) return margins.length === 0;
if (element.closest("li") || !supportsCanonicalInteger(1, 8)(indent, element)) return false;
return margins.length === 0 || margins[0] === `${Number(indent) * 24}px`;
};
const validateMark = (element: Element) => {
const dataColor = element.getAttribute("data-color");
const backgrounds = styleValues(element, "background-color");
const colors = styleValues(element, "color");
if (backgrounds.length > 1 || colors.length > 2) return false;
const semanticColor = dataColor ?? backgrounds[0];
if (!semanticColor) return colors.length === 0;
if (dataColor && backgrounds[0] && normalizedColor(dataColor, element) !== normalizedColor(backgrounds[0], element))
return false;
if (colors.length === 0) return true;
return isDarkColor(semanticColor)
? colors.length === 2 && colors[0] === "inherit" && normalizedColor(colors[1] ?? "", element) === "#ffffff"
: colors.length === 1 && colors[0] === "inherit";
};
const tableStyles = new Map([
...cssRules(["width", "min-width", "max-width", "border-collapse", ...borderStyleProperties]),
["border-spacing", supportsBorderSpacing],
]);
const rowStyles = cssRules(["height", ...borderStyleProperties]);
const cellStyles = new Map([
...cssRules([
"width",
"min-width",
"max-width",
"height",
"padding",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
...borderStyleProperties,
]),
["vertical-align", supportsVerticalAlign],
["text-align", supportsTextAlign(cellAlignments)],
["background-color", supportsColor],
]);
const textBlockStyles = new Map([
["text-align", supportsTextAlign(textAlignments)],
["margin-inline-start", supportsCssValue("margin-inline-start")],
]);
const listItemStyles = new Map([["text-align", supportsTextAlign(textAlignments)]]);
const textStyles = new Map([["color", supportsColor]]);
const highlightStyles = new Map([
["background-color", supportsColor],
["color", supportsColor],
]);
const cellAttributes = new Map<string, ValueValidator>([
["colspan", supportsCanonicalInteger(1, 1000)],
["rowspan", supportsCanonicalInteger(1, 65_534)],
["colwidth", (value, element) => value.split(",").every((width) => supportsCanonicalInteger(1)(width, element))],
["align", (value) => cellAlignments.has(value.trim().toLowerCase())],
]);
const noValues: ElementRule = {};
const textBlockRule: ElementRule = {
attributes: new Map([["data-indent", supportsCanonicalInteger(1, 8)]]),
styles: textBlockStyles,
validate: validateTextBlock,
};
const elementRules = new Map<string, ElementRule>([
["table", { styles: tableStyles }],
["tbody", noValues],
["tr", { styles: rowStyles }],
["td", { attributes: cellAttributes, styles: cellStyles, validate: validateCell }],
["th", { attributes: cellAttributes, styles: cellStyles, validate: validateCell }],
...[...textBlockTags].map((tag): [string, ElementRule] => [tag, textBlockRule]),
["blockquote", noValues],
["ul", noValues],
["ol", { attributes: new Map([["start", supportsCanonicalInteger(Number.MIN_SAFE_INTEGER)]]) }],
["li", { styles: listItemStyles, validate: (element) => styleValues(element, "text-align").length <= 1 }],
["hr", noValues],
["br", noValues],
["strong", noValues],
["b", noValues],
["em", noValues],
["i", noValues],
["u", noValues],
["s", noValues],
["strike", noValues],
["code", noValues],
[
"a",
{
attributes: new Map([
["href", supportsLinkHref],
["target", () => true],
["rel", () => true],
["class", () => true],
["title", () => true],
]),
validate: (element) => element.hasAttribute("href"),
},
],
["span", { styles: textStyles, validate: (element) => styleValues(element, "color").length === 1 }],
["mark", { attributes: new Map([["data-color", supportsColor]]), styles: highlightStyles, validate: validateMark }],
]);
const hasOnlySupportedValues = (element: Element) => {
const rule = elementRules.get(element.tagName.toLowerCase());
if (!rule) return false;
for (const attribute of element.attributes) {
const name = attribute.name.toLowerCase();
if (name === "style" && rule.styles) continue;
const validate = rule.attributes?.get(name);
if (!validate?.(attribute.value, element)) return false;
}
const declarations = readStyleDeclarations(element);
if (!declarations) return false;
if (declarations.length > 0 && !rule.styles) return false;
for (const { property, value } of declarations) {
if (!rule.styles?.get(property)?.(value, element)) return false;
}
return rule.validate?.(element) ?? true;
};
const hasUnsupportedChildNode = (node: Node) =>
node.nodeType === Node.TEXT_NODE ? Boolean(node.textContent?.trim()) : node.nodeType !== Node.ELEMENT_NODE;
const hasOnlyInlineContent = (element: Element): boolean =>
Array.from(element.childNodes).every((child) => {
if (child.nodeType === Node.TEXT_NODE) return true;
if (!(child instanceof Element) || !inlineTags.has(child.tagName.toLowerCase())) return false;
return hasOnlyInlineContent(child);
});
const hasOnlyCellBlockContent = (element: Element): boolean =>
Array.from(element.childNodes).every((child) => {
if (child.nodeType === Node.TEXT_NODE) return true;
if (!(child instanceof Element)) return false;
const tagName = child.tagName.toLowerCase();
if (inlineTags.has(tagName)) return hasOnlyInlineContent(child);
if (!cellBlockTags.has(tagName)) return false;
if (textBlockTags.has(tagName)) return hasOnlyInlineContent(child);
if (tagName === "hr") return child.childNodes.length === 0;
if (tagName === "blockquote") {
const hasContent = Array.from(child.childNodes).some(
(blockChild) => blockChild.nodeType === Node.ELEMENT_NODE || Boolean(blockChild.textContent?.trim()),
);
return hasContent && hasOnlyCellBlockContent(child);
}
const listItems = Array.from(child.children);
if (listItems.length === 0) return false;
return Array.from(child.childNodes).every((listChild) => {
if (listChild.nodeType === Node.TEXT_NODE) return !listChild.textContent?.trim();
if (!(listChild instanceof Element) || listChild.tagName.toLowerCase() !== "li") return false;
return hasOnlyCellBlockContent(listChild);
});
});
const hasRectangularTableGrid = (rows: readonly Element[]) => {
let expectedWidth: number | undefined;
const occupiedUntil: number[] = [];
for (const [rowIndex, row] of rows.entries()) {
const coverage = occupiedUntil.map((endRow) => endRow > rowIndex);
let column = 0;
for (const cell of Array.from(row.children)) {
while (coverage[column]) column++;
const colspan = Number(cell.getAttribute("colspan") ?? 1);
const rowspan = Number(cell.getAttribute("rowspan") ?? 1);
if (rowIndex + rowspan > rows.length) return false;
for (let offset = 0; offset < colspan; offset++) {
if (coverage[column + offset]) return false;
coverage[column + offset] = true;
occupiedUntil[column + offset] = rowIndex + rowspan;
}
column += colspan;
}
const rowWidth = coverage.lastIndexOf(true) + 1;
if (rowWidth === 0 || coverage.slice(0, rowWidth).some((covered) => !covered)) return false;
expectedWidth ??= rowWidth;
if (rowWidth !== expectedWidth) return false;
}
return true;
};
const hasUnsupportedCellDescendant = (element: Element) => {
const tagName = element.tagName.toLowerCase();
return !inlineTags.has(tagName) && !cellBlockTags.has(tagName) && tagName !== "li"
? true
: !hasOnlySupportedValues(element);
};
const sourceTablesFrom = (html: string) =>
Array.from(html.matchAll(/<table\b[^>]*>[\s\S]*?<\/table\s*>/gi), (match) => match[0]);
const tableMarkersFrom = (html: string) => Array.from(html.matchAll(/<\/?table(?=\s|\/?>|$)/gi));
const openingTableMarkersFrom = (html: string) => Array.from(html.matchAll(/<table(?=\s|\/?>|$)/gi));
const parsedTablesMatchSource = (sourceTables: readonly string[], tables: readonly HTMLTableElement[]) => {
return (
sourceTables.length === tables.length && sourceTables.every((source, index) => source === tables[index]?.outerHTML)
);
};
const hasUnsupportedTableMarkup = (html: string) => {
if (typeof DOMParser === "undefined") return false;
const tableMarkers = tableMarkersFrom(html);
if (tableMarkers.length === 0) return false;
const document = new DOMParser().parseFromString(html, "text/html");
const tables = Array.from(document.querySelectorAll("table"));
const sourceTables = sourceTablesFrom(html);
const openingTableMarkers = openingTableMarkersFrom(html);
if (
tables.length === 0 ||
sourceTables.length === 0 ||
openingTableMarkers.length !== sourceTables.length ||
tableMarkers.length !== sourceTables.length * 2 ||
!parsedTablesMatchSource(sourceTables, tables)
)
return true;
for (const table of tables) {
if (!hasOnlySupportedValues(table)) return true;
const bodies = Array.from(table.children);
if (bodies.length !== 1 || bodies[0]?.tagName.toLowerCase() !== "tbody") return true;
if (Array.from(table.childNodes).some(hasUnsupportedChildNode)) return true;
const body = bodies[0];
if (!body || !hasOnlySupportedValues(body)) return true;
if (Array.from(body.childNodes).some(hasUnsupportedChildNode)) return true;
const rows = Array.from(body.children);
if (rows.length === 0 || rows.some((row) => row.tagName.toLowerCase() !== "tr")) return true;
for (const row of rows) {
if (!hasOnlySupportedValues(row)) return true;
if (Array.from(row.childNodes).some(hasUnsupportedChildNode)) return true;
if (Array.from(row.children).some((cell) => !["td", "th"].includes(cell.tagName.toLowerCase()))) return true;
}
for (const cell of table.querySelectorAll("td, th")) {
if (!hasOnlySupportedValues(cell)) return true;
if (!hasOnlyCellBlockContent(cell)) return true;
if (Array.from(cell.querySelectorAll("*"), hasUnsupportedCellDescendant).some(Boolean)) return true;
}
if (!hasRectangularTableGrid(rows)) return true;
}
return false;
};
const preservedStyle = {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute("style"),
renderHTML: (attributes: { style?: string | null }) => (attributes.style ? { style: attributes.style } : {}),
};
const StyledTable = Table.extend({
addAttributes() {
return { ...this.parent?.(), style: preservedStyle };
},
renderHTML({ HTMLAttributes }) {
return ["table", HTMLAttributes, ["tbody", 0]];
},
});
const StyledTableRow = TableRow.extend({
addAttributes() {
return { ...this.parent?.(), style: preservedStyle };
},
});
const StyledTableHeader = TableHeader.extend({
addAttributes() {
return { ...this.parent?.(), style: preservedStyle };
},
});
const StyledTableCell = TableCell.extend({
addAttributes() {
return { ...this.parent?.(), style: preservedStyle };
},
});
const extensions = [
StarterKit.configure({
heading: {
@@ -88,6 +507,10 @@ const extensions = [
}),
TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }),
ParagraphIndent,
StyledTable,
StyledTableRow,
StyledTableHeader,
StyledTableCell,
];
type Props = UseEditorOptions & {
@@ -111,12 +534,15 @@ export function RichInput({
const { i18n } = useLingui();
const textDirection = isRTL(i18n.locale) ? "rtl" : undefined;
const [isFullscreen, setIsFullscreen] = useState(false);
const hasUnsupportedTable = useMemo(() => hasUnsupportedTableMarkup(value), [value]);
const requestedEditable = options.editable ?? true;
const editor = useEditor({
...options,
extensions,
textDirection,
content: value,
editable: requestedEditable && !hasUnsupportedTable,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
editorProps: {
@@ -136,6 +562,7 @@ export function RichInput({
},
},
onUpdate: ({ editor }) => {
if (hasUnsupportedTable) return;
onChange(editor.getHTML());
},
});
@@ -147,11 +574,26 @@ export function RichInput({
editor.commands.setContent(value, { emitUpdate: false });
}, [editor, value]);
useEffect(() => {
if (!editor) return;
editor.setEditable(requestedEditable && !hasUnsupportedTable, false);
}, [editor, hasUnsupportedTable, requestedEditable]);
if (!editor) return null;
const editorElement = (
<div className="relative">
<EditorToolbar editor={editor} isFullscreen={isFullscreen} />
{hasUnsupportedTable ? (
<div role="status" className="rounded-md rounded-b-none border border-b-0 bg-muted px-3 py-2 text-sm">
<span>
<Trans>
Original table formatting is preserved. This content is read-only because it cannot be edited safely.
</Trans>
</span>
</div>
) : (
<EditorToolbar editor={editor} isFullscreen={isFullscreen} />
)}
<EditorContent editor={editor} />
@@ -2,19 +2,20 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { describe, expect, it } from "vitest";
import { renderToBuffer } from "@react-pdf/renderer";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { getDocument, OPS } from "pdfjs-dist/legacy/build/pdf.mjs";
import { act } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
import { rasterizePdf } from "./test/rasterize-pdf";
const table = (paragraphs = false) =>
`<table style="width: 240pt; border-collapse: collapse"><tbody>${[
["Alpha", "Beta"],
["Gamma", "Delta"],
`<table style="width: 300pt; border-collapse: collapse"><tbody>${[
["Alpha", "Beta", "Gamma"],
["Delta", "Epsilon", "Zeta"],
]
.map(
(row) =>
`<tr>${row.map((text) => `<td style="width: 120pt; border: 1pt solid black; padding: 4pt">${paragraphs ? `<p>${text}</p>` : text}</td>`).join("")}</tr>`,
`<tr>${row.map((text) => `<td style="width: 100pt; border: 1pt solid black; padding: 4pt">${paragraphs ? `<p>${text}</p>` : text}</td>`).join("")}</tr>`,
)
.join("")}</tbody></table>`;
@@ -32,24 +33,71 @@ const fixture = (html: string, mode: "legacy" | "semantic", css = ""): ResumeDat
const readPdf = async (data: ResumeData, template: Template) => {
const bytes = new Uint8Array(await act(() => renderToBuffer(<ResumeDocument data={data} template={template} />)));
const loading = getDocument({ data: bytes });
const loading = getDocument({ data: bytes.slice() });
try {
const document = await loading.promise;
const page = await document.getPage(1);
const content = await page.getTextContent();
return content.items.flatMap((item) =>
"str" in item ? [{ text: item.str, x: item.transform[4], y: item.transform[5] }] : [],
);
const operators = await page.getOperatorList();
return {
bytes,
operators,
items: content.items.flatMap((item) =>
"str" in item ? [{ text: item.str, x: item.transform[4], y: item.transform[5] }] : [],
),
};
} finally {
await loading.destroy();
}
};
const inspectTableBorders = async ({ bytes, operators }: Awaited<ReturnType<typeof readPdf>>) => {
let stroke = "";
let horizontal = 0;
let vertical = 0;
let anyHorizontal = 0;
let anyVertical = 0;
for (const [index, fn] of operators.fnArray.entries()) {
if (fn === OPS.setStrokeRGBColor) stroke = operators.argsArray[index][0];
if (fn !== OPS.constructPath) continue;
const bounds = operators.argsArray[index][2] as ArrayLike<number>;
const width = Math.abs((bounds[2] ?? 0) - (bounds[0] ?? 0));
const height = Math.abs((bounds[3] ?? 0) - (bounds[1] ?? 0));
if (height > 0 && height <= 1.01 && width > height) {
anyHorizontal++;
if (stroke === "#cc00cc") horizontal++;
}
if (width > 0 && width <= 1.01 && height > width) {
anyVertical++;
if (stroke === "#cc00cc") vertical++;
}
}
const [page] = await rasterizePdf(bytes);
if (!page) throw new Error("Missing rendered page");
let pixels = 0;
for (let index = 0; index < page.data.length; index += 4) {
if ((page.data[index] ?? 0) > 180 && (page.data[index + 1] ?? 255) < 80 && (page.data[index + 2] ?? 0) > 180)
pixels++;
}
return {
colored: { horizontal, vertical, pixels },
geometry: { horizontal: anyHorizontal, vertical: anyVertical },
};
};
const tableCoordinates = (items: Awaited<ReturnType<typeof readPdf>>["items"]) =>
Object.fromEntries(
items
.filter(({ text }) => ["Alpha", "Beta", "Beta!", "Gamma", "Delta", "Epsilon", "Zeta"].includes(text))
.map(({ text, x, y }) => [text, [Number(x.toFixed(3)), Number(y.toFixed(3))]]),
);
describe("imported rich-text tables", () => {
for (const template of ["ditgar", "onyx"] as const) {
for (const mode of ["legacy", "semantic"] as const) {
it(`${template} ${mode} preserves bare cell text and row/column positions`, async () => {
const items = await readPdf(fixture(table(), mode), template);
const { items } = await readPdf(fixture(table(), mode), template);
const cell = (text: string) => {
const cell = items.find((item) => item.text === text);
if (!cell) throw new Error(`Missing table cell ${text}`);
@@ -59,29 +107,72 @@ describe("imported rich-text tables", () => {
const beta = cell("Beta");
const gamma = cell("Gamma");
const delta = cell("Delta");
const epsilon = cell("Epsilon");
const zeta = cell("Zeta");
expect(alpha.y).toBe(beta.y);
expect(gamma.y).toBe(delta.y);
expect(alpha.y).toBeGreaterThan(gamma.y);
expect(alpha.x).toBe(gamma.x);
expect(beta.x).toBe(delta.x);
expect(alpha.y).toBe(gamma.y);
expect(delta.y).toBe(epsilon.y);
expect(delta.y).toBe(zeta.y);
expect(alpha.y).toBeGreaterThan(delta.y);
expect(alpha.x).toBe(delta.x);
expect(beta.x).toBe(epsilon.x);
expect(gamma.x).toBe(zeta.x);
expect(beta.x).toBeGreaterThan(alpha.x);
expect(gamma.x).toBeGreaterThan(beta.x);
});
}
}
it("preserves table cells containing recognized paragraphs", async () => {
const items = await readPdf(fixture(table(true), "semantic"), "ditgar");
const { items } = await readPdf(fixture(table(true), "semantic"), "ditgar");
expect(items.map((item) => item.text)).toEqual(expect.arrayContaining(["Alpha", "Beta", "Gamma", "Delta"]));
});
it("preserves raw text inside an unrecognized block wrapper", async () => {
const items = await readPdf(fixture("<div>Wrapper content</div>", "semantic"), "ditgar");
const { items } = await readPdf(fixture("<div>Wrapper content</div>", "semantic"), "ditgar");
expect(items.map((item) => item.text)).toContain("Wrapper content");
});
it("still honors explicit semantic rich-text hiding", async () => {
const items = await readPdf(fixture(table(), "semantic", "rich-text { display: none; }"), "ditgar");
const { items } = await readPdf(fixture(table(), "semantic", "rich-text { display: none; }"), "ditgar");
expect(items.map((item) => item.text)).toContain("Table probe");
expect(items.map((item) => item.text)).not.toContain("Alpha");
});
it.each(["legacy", "semantic"] as const)(
"keeps six cell coordinates, border operators, and fixed-DPI pixels through %s persistence",
async (mode) => {
const bordered = table().replaceAll("black", "#cc00cc");
const unrelated = fixture(bordered, mode);
unrelated.basics.name = "Border Probe unrelated edit";
const stages = [
{ name: "original", data: fixture(bordered, mode), expectedBeta: "Beta" },
{ name: "unrelated edit", data: unrelated, expectedBeta: "Beta" },
{ name: "table edit", data: fixture(bordered.replace("Beta", "Beta!"), mode), expectedBeta: "Beta!" },
];
for (const stage of stages) {
const pdf = await readPdf(stage.data, "ditgar");
expect(tableCoordinates(pdf.items), stage.name).toEqual({
Alpha: [227.348, 808.69],
[stage.expectedBeta]: [327.348, 808.69],
Gamma: [427.348, 808.69],
Delta: [227.348, 778.89],
Epsilon: [327.348, 778.89],
Zeta: [427.348, 778.89],
});
expect((await inspectTableBorders(pdf)).colored, stage.name).toEqual({
horizontal: 17,
vertical: 12,
pixels: 1851,
});
}
},
30_000,
);
it.each(["legacy", "semantic"] as const)("keeps borderless tables borderless in %s mode", async (mode) => {
const borderless = table().replaceAll("border: 1pt solid black; ", "");
const pdf = await readPdf(fixture(borderless, mode), "ditgar");
expect((await inspectTableBorders(pdf)).geometry).toEqual({ horizontal: 0, vertical: 0 });
});
});
+14
View File
@@ -454,6 +454,9 @@ importers:
'@tiptap/extension-highlight':
specifier: ^3.31.2
version: 3.31.2(@tiptap/core@3.31.2(@tiptap/pm@3.31.2))
'@tiptap/extension-table':
specifier: ^3.31.2
version: 3.31.2(@tiptap/core@3.31.2(@tiptap/pm@3.31.2))(@tiptap/pm@3.31.2)
'@tiptap/extension-text-align':
specifier: ^3.31.2
version: 3.31.2(@tiptap/core@3.31.2(@tiptap/pm@3.31.2))
@@ -4546,6 +4549,12 @@ packages:
peerDependencies:
'@tiptap/core': 3.31.2
'@tiptap/extension-table@3.31.2':
resolution: {integrity: sha512-0LfcQG2YK2Q3q5BjvJqs8Zks1PuSdiNGYf2lnyC7ixtItuKXTL2xB5yzj53gMIdRfteqcUfbxIJjmU57W3F+Yg==}
peerDependencies:
'@tiptap/core': 3.31.2
'@tiptap/pm': 3.31.2
'@tiptap/extension-text-align@3.31.2':
resolution: {integrity: sha512-ZHBpokt4iz0fA67K1TofmWhVxVYpw1TK03XmdZQucE5eWGqI6nBmv/eWrkk3j3gAaY17jBgo+vKUt9N2L0H3zA==}
peerDependencies:
@@ -11982,6 +11991,11 @@ snapshots:
dependencies:
'@tiptap/core': 3.31.2(@tiptap/pm@3.31.2)
'@tiptap/extension-table@3.31.2(@tiptap/core@3.31.2(@tiptap/pm@3.31.2))(@tiptap/pm@3.31.2)':
dependencies:
'@tiptap/core': 3.31.2(@tiptap/pm@3.31.2)
'@tiptap/pm': 3.31.2
'@tiptap/extension-text-align@3.31.2(@tiptap/core@3.31.2(@tiptap/pm@3.31.2))':
dependencies:
'@tiptap/core': 3.31.2(@tiptap/pm@3.31.2)
+201
View File
@@ -0,0 +1,201 @@
import type { Page, TestInfo } from "@playwright/test";
import { readFile, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { Pool } from "pg";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ACTIVE_PREVIEW_PAGE_SELECTOR } from "../fixtures/preview";
import { openSidebarSection } from "../fixtures/resume";
import { expect, test } from "../fixtures/test";
const requireWeb = createRequire(`${process.cwd()}/apps/web/package.json`);
const tableHtml = `<table style="width: 300pt; border-collapse: collapse"><tbody><tr><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Alpha</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Beta</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Gamma</td></tr><tr><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Delta</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Epsilon</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Zeta</td></tr></tbody></table>`;
const fixture = () => {
const data = structuredClone(defaultResumeData);
data.basics.name = "Imported Table Probe";
data.picture.hidden = true;
data.summary.content = tableHtml;
data.metadata.template = "ditgar";
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
data.metadata.typography.body.fontFamily = "Helvetica";
data.metadata.typography.heading.fontFamily = "Helvetica";
data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 1, text: "@version 1;" } };
return data;
};
async function readImportedResume(id: string) {
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for imported table E2E.");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
const result = await pool.query<{
data: { basics: { name: string }; summary: { content: string } };
slug: string;
updatedAt: Date;
}>('select data, slug, updated_at as "updatedAt" from "resume" where id = $1', [id]);
const row = result.rows[0];
if (!row) throw new Error(`Resume ${id} was not found.`);
return row;
} finally {
await pool.end();
}
}
async function publishImportedResume(id: string) {
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for imported table E2E.");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
await pool.query('update "resume" set is_public = true where id = $1', [id]);
} finally {
await pool.end();
}
}
async function inspectPdf(bytes: Uint8Array) {
const { getDocument, OPS } = await import(requireWeb.resolve("pdfjs-dist/legacy/build/pdf.mjs"));
const loading = getDocument({ data: bytes.slice(), useSystemFonts: true });
try {
const document = await loading.promise;
const page = await document.getPage(1);
const text = (await page.getTextContent()).items.flatMap((item: { str?: string }) => item.str ?? []);
const operators = await page.getOperatorList();
let stroke = "";
let horizontal = 0;
let vertical = 0;
for (const [index, fn] of operators.fnArray.entries()) {
if (fn === OPS.setStrokeRGBColor) stroke = operators.argsArray[index][0];
if (fn !== OPS.constructPath || stroke !== "#cc00cc") continue;
const bounds = operators.argsArray[index][2] as ArrayLike<number>;
const width = Math.abs((bounds[2] ?? 0) - (bounds[0] ?? 0));
const height = Math.abs((bounds[3] ?? 0) - (bounds[1] ?? 0));
if (height > 0 && height <= 1.01 && width > height) horizontal++;
if (width > 0 && width <= 1.01 && height > width) vertical++;
}
return { text, horizontal, vertical };
} finally {
await loading.destroy();
}
}
async function downloadBuilderPdf(page: Page, testInfo: TestInfo, stage: string) {
await openSidebarSection(page, "Export");
await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click();
const pending = page.waitForEvent("download");
await page.getByRole("button", { name: "Download PDF", exact: true }).click();
const download = await pending;
const path = testInfo.outputPath(`${stage}-browser.pdf`);
await download.saveAs(path);
return new Uint8Array(await readFile(path));
}
async function previewBorderPixels(page: Page, testInfo: TestInfo, stage: string) {
const canvas = page.locator(ACTIVE_PREVIEW_PAGE_SELECTOR);
await expect(canvas).toBeVisible({ timeout: 30_000 });
await canvas.screenshot({ path: testInfo.outputPath(`${stage}-preview.png`), animations: "disabled" });
return canvas.evaluate((element) => {
const context = (element as HTMLCanvasElement).getContext("2d");
if (!context) throw new Error("Missing preview canvas context.");
const pixels = context.getImageData(0, 0, context.canvas.width, context.canvas.height).data;
let magenta = 0;
for (let index = 0; index < pixels.length; index += 4) {
if ((pixels[index] ?? 0) > 180 && (pixels[index + 1] ?? 255) < 80 && (pixels[index + 2] ?? 0) > 180) magenta++;
}
return magenta;
});
}
test("edits and exports a synthetic imported table without losing its grid", async ({
authPage: page,
account,
}, testInfo) => {
test.setTimeout(180_000);
const importPath = testInfo.outputPath("imported-table.json");
await writeFile(importPath, JSON.stringify(fixture()));
await page.goto("/dashboard/resumes");
await page.getByText("Import an existing resume", { exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Import an existing resume" });
await dialog.locator('input[type="file"]').setInputFiles(importPath);
await dialog.getByRole("button", { name: "Import", exact: true }).click();
await page.waitForURL(/\/builder\/.+/);
const id = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1];
if (!id) throw new Error("Missing imported resume id.");
await publishImportedResume(id);
let stored = await readImportedResume(id);
const slug = stored.slug;
const captureStage = async (stage: string, expectedCell: string) => {
const summaryHtml = (await readImportedResume(id)).data.summary.content;
await writeFile(testInfo.outputPath(`${stage}-summary.html`), summaryHtml);
expect(summaryHtml).toContain("<table");
expect(summaryHtml).toContain(expectedCell);
expect(await previewBorderPixels(page, testInfo, stage)).toBeGreaterThan(1_000);
for (const [surface, bytes] of [
["browser", await downloadBuilderPdf(page, testInfo, stage)],
[
"server",
new Uint8Array(
await (
await page.request.get(
`/api/resumes/${encodeURIComponent(account.username)}/${encodeURIComponent(slug)}/pdf`,
)
).body(),
),
],
] as const) {
await writeFile(testInfo.outputPath(`${stage}-${surface}.pdf`), bytes);
const pdf = await inspectPdf(bytes);
expect(pdf.text, `${stage} ${surface}`).toEqual(
expect.arrayContaining(["Alpha", expectedCell, "Gamma", "Delta", "Epsilon", "Zeta"]),
);
expect({ horizontal: pdf.horizontal, vertical: pdf.vertical }, `${stage} ${surface}`).toEqual({
horizontal: 17,
vertical: 12,
});
}
};
await openSidebarSection(page, "Summary");
const summary = page.locator("#sidebar-summary");
await expect(summary.locator("table tr")).toHaveCount(2);
await expect(summary.locator("table td")).toHaveCount(6);
expect(stored.data.summary.content).toBe(tableHtml);
await captureStage("initial", "Beta");
await openSidebarSection(page, "Basics");
const unrelatedName = "Imported Table Probe unrelated edit";
const beforeUnrelatedEdit = stored.updatedAt;
await page.getByLabel("Name").fill(unrelatedName);
await expect.poll(async () => (await readImportedResume(id)).data.basics.name).toBe(unrelatedName);
stored = await readImportedResume(id);
expect(stored.updatedAt.getTime()).toBeGreaterThan(beforeUnrelatedEdit.getTime());
await page.reload();
stored = await readImportedResume(id);
expect(stored.data.basics.name).toBe(unrelatedName);
expect(stored.data.summary.content).toBe(tableHtml);
await openSidebarSection(page, "Basics");
await expect(page.getByLabel("Name")).toHaveValue(unrelatedName);
await openSidebarSection(page, "Summary");
await expect(summary.locator("table td")).toHaveCount(6);
await captureStage("unrelated-edit", "Beta");
const beta = summary.locator("td").filter({ hasText: "Beta" });
await beta.click();
await page.keyboard.press("End");
await page.keyboard.type("!");
await expect(beta).toContainText("Beta!");
await page.keyboard.press("Control+z");
await expect(beta).toContainText("Beta");
await page.keyboard.press("Control+Shift+z");
await expect(beta).toContainText("Beta!");
await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible();
await page.reload();
await openSidebarSection(page, "Summary");
await expect(summary.locator("table tr")).toHaveCount(2);
await expect(summary.locator("table td")).toHaveCount(6);
await expect(summary.locator("td").filter({ hasText: "Beta!" })).toBeVisible();
stored = await readImportedResume(id);
expect(stored.data.summary.content).toContain("<table");
expect(stored.data.summary.content).toContain("Beta!");
await captureStage("table-edit", "Beta!");
});
+4 -4
View File
@@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { compareResumeRecovery } from "./compare-resume";
const SYNTHETIC_SOURCE_HASH = "68cbff28a704f3859c7f5385e9e82a3517521374d99ec2f65ca15887f81310cc";
const RECOVERED_COPY_HASH = "56d3e7d3ecd336b6d910224e2ecb64c7a3c010ff980782f3bea985682018e3a6";
const CURRENT_COPY_HASH = "40cb0aba1e7b3d0950314c3ad20a74b30785545658b296fea97885d6364c9b2f";
const DEFAULT_RESUME_HASH = "15c8a97e15f248c630a6e1c16e5e257a5b02959ef749acbc18c41cd150e853a4";
const SYNTHETIC_SOURCE_HASH = "a544b2abf225396f8c16cd2ea1c182d195a8d9179d3a95be4b1b1e7d43722e11";
const RECOVERED_COPY_HASH = "704cd69adcd227d6975aca64afea9a63fe0eeb8e17c275b4c79627d85bdd5791";
const CURRENT_COPY_HASH = "33d337241cd4ad8cf0faa2cdc0107fa4d607b3070f27dab7166c206b05afd882";
const DEFAULT_RESUME_HASH = "a6930afc82a24637de715bdcac5300318261d3b69969527f43917904478e885b";
const FORMAT_CHARACTERS = [
["zero-width space (U+200B)", "\u200B"],