Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

375 lines
10 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
_portableTextToProsemirror,
_prosemirrorToPortableText,
} from "../../src/components/PortableTextEditor";
type ListBlock = {
_type: "block";
style: "normal";
listItem: "bullet" | "number";
level: number;
children: Array<{ _type: "span"; text: string }>;
};
function isListBlock(b: unknown): b is ListBlock {
return (
typeof b === "object" &&
b !== null &&
(b as { _type?: unknown })._type === "block" &&
"listItem" in (b as Record<string, unknown>)
);
}
describe("ProseMirror → PortableText: nested list level", () => {
it("emits level=1 for a single-level bullet list", () => {
const pmDoc = {
type: "doc",
content: [
{
type: "bulletList",
content: [
{
type: "listItem",
content: [{ type: "paragraph", content: [{ type: "text", text: "Item one" }] }],
},
{
type: "listItem",
content: [{ type: "paragraph", content: [{ type: "text", text: "Item two" }] }],
},
],
},
],
};
const result = _prosemirrorToPortableText(pmDoc).filter(isListBlock);
expect(result.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([
["bullet", 1, "Item one"],
["bullet", 1, "Item two"],
]);
});
it("emits level=2 for bullets nested inside a parent bullet", () => {
const pmDoc = {
type: "doc",
content: [
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Parent" }] },
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Child" }],
},
],
},
],
},
],
},
],
},
],
};
const result = _prosemirrorToPortableText(pmDoc).filter(isListBlock);
expect(result.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([
["bullet", 1, "Parent"],
["bullet", 2, "Child"],
]);
});
it("preserves listItem type when an ordered list nests inside a bullet", () => {
const pmDoc = {
type: "doc",
content: [
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Bullet top" }] },
{
type: "orderedList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Numbered child" }],
},
],
},
],
},
],
},
],
},
],
};
const result = _prosemirrorToPortableText(pmDoc).filter(isListBlock);
expect(result.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([
["bullet", 1, "Bullet top"],
["number", 2, "Numbered child"],
]);
});
it("handles three-level nesting", () => {
const pmDoc = {
type: "doc",
content: [
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "L1" }] },
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "L2" }] },
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "L3" }],
},
],
},
],
},
],
},
],
},
],
},
],
},
],
};
const result = _prosemirrorToPortableText(pmDoc).filter(isListBlock);
expect(result.map((b) => [b.level, b.children[0]?.text])).toEqual([
[1, "L1"],
[2, "L2"],
[3, "L3"],
]);
});
});
type PMList = {
type: "bulletList" | "orderedList";
content: Array<{
type: "listItem";
content: Array<{ type: string; content?: unknown[] }>;
}>;
};
function findFirstList(node: { content?: unknown[] }): PMList | null {
if (!node.content) return null;
for (const child of node.content as Array<{ type?: string }>) {
if (child.type === "bulletList" || child.type === "orderedList") return child as PMList;
}
return null;
}
function getParagraphText(listItem: { content?: unknown[] }): string | undefined {
if (!listItem.content) return undefined;
const para = (listItem.content as Array<{ type?: string; content?: unknown[] }>).find(
(c) => c.type === "paragraph",
);
const text = (para?.content as Array<{ type?: string; text?: string }> | undefined)?.find(
(c) => c.type === "text",
);
return text?.text;
}
function getNestedList(listItem: { content?: unknown[] }): PMList | undefined {
return (listItem.content as Array<{ type?: string }> | undefined)?.find(
(c) => c.type === "bulletList" || c.type === "orderedList",
) as PMList | undefined;
}
function pt(
listItem: "bullet" | "number",
level: number,
text: string,
): {
_type: "block";
_key: string;
style: "normal";
listItem: "bullet" | "number";
level: number;
children: Array<{ _type: "span"; _key: string; text: string }>;
} {
return {
_type: "block",
_key: `b${level}-${text}`,
style: "normal",
listItem,
level,
children: [{ _type: "span", _key: `s-${text}`, text }],
};
}
describe("PortableText → ProseMirror: nested list level", () => {
it("nests level=2 bullets inside their parent listItem", () => {
const result = _portableTextToProsemirror([
pt("bullet", 1, "Parent"),
pt("bullet", 2, "Child"),
]);
const list = findFirstList(result);
expect(list).toBeTruthy();
expect(list!.type).toBe("bulletList");
expect(list!.content).toHaveLength(1);
expect(getParagraphText(list!.content[0]!)).toBe("Parent");
const nested = getNestedList(list!.content[0]!);
expect(nested?.type).toBe("bulletList");
expect(nested?.content).toHaveLength(1);
expect(getParagraphText(nested!.content[0]!)).toBe("Child");
});
it("preserves listType when an ordered list nests inside a bullet", () => {
const result = _portableTextToProsemirror([
pt("bullet", 1, "Bullet top"),
pt("number", 2, "Numbered child"),
]);
const outer = findFirstList(result);
expect(outer?.type).toBe("bulletList");
expect(outer?.content).toHaveLength(1);
const inner = getNestedList(outer!.content[0]!);
expect(inner?.type).toBe("orderedList");
expect(inner?.content).toHaveLength(1);
expect(getParagraphText(inner!.content[0]!)).toBe("Numbered child");
});
it("does not flatten level=2 siblings into a top-level number list", () => {
// Regression for the outer-loop run grouping: a number block at level=2
// must be folded into its parent bullet's run, not start a new top-level
// orderedList.
const result = _portableTextToProsemirror([
pt("bullet", 1, "Parent"),
pt("number", 2, "Numbered child"),
pt("bullet", 1, "Sibling"),
]);
const lists = (result.content as Array<{ type?: string }>).filter(
(c) => c.type === "bulletList" || c.type === "orderedList",
) as PMList[];
expect(lists).toHaveLength(1);
expect(lists[0]!.type).toBe("bulletList");
expect(lists[0]!.content).toHaveLength(2);
expect(getParagraphText(lists[0]!.content[0]!)).toBe("Parent");
expect(getParagraphText(lists[0]!.content[1]!)).toBe("Sibling");
expect(getNestedList(lists[0]!.content[0]!)?.type).toBe("orderedList");
});
it("handles three-level nesting", () => {
const result = _portableTextToProsemirror([
pt("bullet", 1, "L1"),
pt("bullet", 2, "L2"),
pt("bullet", 3, "L3"),
]);
const l1 = findFirstList(result);
expect(getParagraphText(l1!.content[0]!)).toBe("L1");
const l2 = getNestedList(l1!.content[0]!);
expect(l2?.type).toBe("bulletList");
expect(getParagraphText(l2!.content[0]!)).toBe("L2");
const l3 = getNestedList(l2!.content[0]!);
expect(l3?.type).toBe("bulletList");
expect(getParagraphText(l3!.content[0]!)).toBe("L3");
});
it("keeps deeper nesting under its true parent for mixed-type 3-level trees", () => {
// Regression for convertPTListItem's nested grouping: it used to
// break the group on every `listItem` change regardless of depth,
// so a level-3 block ended up as a sibling sub-list under the
// level-1 item instead of nesting under the matching level-2 item
// — and the round-trip would degrade level-3 to level-2.
const original = [
pt("bullet", 1, "A"),
pt("number", 2, "B"),
pt("bullet", 3, "C"),
pt("number", 2, "D"),
];
const pm = _portableTextToProsemirror(original);
const outer = findFirstList(pm);
expect(outer?.type).toBe("bulletList");
expect(outer?.content).toHaveLength(1);
expect(getParagraphText(outer!.content[0]!)).toBe("A");
const numbered = getNestedList(outer!.content[0]!);
expect(numbered?.type).toBe("orderedList");
expect(numbered?.content).toHaveLength(2);
expect(getParagraphText(numbered!.content[0]!)).toBe("B");
expect(getParagraphText(numbered!.content[1]!)).toBe("D");
const cInBullets = getNestedList(numbered!.content[0]!);
expect(cInBullets?.type).toBe("bulletList");
expect(getParagraphText(cInBullets!.content[0]!)).toBe("C");
// Round-trip must keep C at level 3, not collapse it to level 2.
const roundTripped = _prosemirrorToPortableText(pm).filter(
(b): b is (typeof original)[number] =>
typeof b === "object" && b !== null && (b as { _type?: string })._type === "block",
);
expect(roundTripped.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([
["bullet", 1, "A"],
["number", 2, "B"],
["bullet", 3, "C"],
["number", 2, "D"],
]);
});
});
describe("Round-trip: PT → PM → PT preserves nested list level", () => {
it("keeps level and listItem for a 2-level bullet → number tree", () => {
const original = [
pt("bullet", 1, "Top"),
pt("number", 2, "Nested"),
pt("bullet", 1, "Sibling"),
];
const pm = _portableTextToProsemirror(original);
const roundTripped = _prosemirrorToPortableText(pm).filter(
(b): b is (typeof original)[number] =>
typeof b === "object" && b !== null && (b as { _type?: string })._type === "block",
);
expect(roundTripped.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([
["bullet", 1, "Top"],
["number", 2, "Nested"],
["bullet", 1, "Sibling"],
]);
});
});