Merge gitea/develop into develop
Some checks failed
Develop / test (push) Has been cancelled
Develop / build (push) Has been cancelled
Some checks failed
Develop / test (push) Has been cancelled
Develop / build (push) Has been cancelled
Reconcile the diverged develop (13 ahead / 20 behind) with gitea/develop. Conflict resolution — html-embed: keep the local sandboxed-iframe model (opaque-origin srcdoc, no role-gating) and supersede gitea's same-origin strip/kill-switch hardening (#26/#28/#29/#30). The 4 conflicted html-embed source files resolve to the local version; the 3 strip-era spec files stay deleted. The strip apparatus (stripDisallowedHtmlEmbedNodes, collectHtmlEmbedSources, canAuthorHtmlEmbed, htmlEmbedAllowed) is fully gone. Integrate gitea's page-templates / page-embed work (#31-#40) cleanly. Fix an auto-merge arity mismatch: two new gitea page-template specs constructed TransclusionService with the pre-sandbox 11-arg signature; drop the trailing workspaceRepo argument to match the reduced 10-arg constructor. Verified: server + client tsc --noEmit clean; jest (html-embed + transclusion) 14 suites / 119 tests passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,71 +1,149 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
PageEmbedAncestryProvider,
|
||||
usePageEmbedAncestry,
|
||||
isPageEmbedCycle,
|
||||
isPageEmbedTooDeep,
|
||||
PAGE_EMBED_MAX_DEPTH,
|
||||
} from "./page-embed-ancestry-context";
|
||||
|
||||
// Probe child: renders the current ancestry context value as JSON so the test
|
||||
// can assert on the accumulated chain and host without any Tiptap editor.
|
||||
function Probe({ testId }: { testId: string }) {
|
||||
const ancestry = usePageEmbedAncestry();
|
||||
return <div data-testid={testId}>{JSON.stringify(ancestry)}</div>;
|
||||
}
|
||||
|
||||
function read(el: HTMLElement) {
|
||||
return JSON.parse(el.textContent || "{}") as {
|
||||
chain: string[];
|
||||
hostPageId: string | null;
|
||||
};
|
||||
/**
|
||||
* Tiny probe that renders the current ancestry context as serialized data
|
||||
* attributes so tests can assert the accumulated chain / threaded hostPageId
|
||||
* without mounting the heavy Tiptap node view.
|
||||
*/
|
||||
function AncestryProbe({ testId = "probe" }: { testId?: string }) {
|
||||
const { chain, hostPageId } = usePageEmbedAncestry();
|
||||
return (
|
||||
<span
|
||||
data-testid={testId}
|
||||
data-chain={chain.join(",")}
|
||||
data-chain-length={String(chain.length)}
|
||||
data-host={hostPageId ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PageEmbedAncestryProvider", () => {
|
||||
it("accumulates the chain in order across nested providers", () => {
|
||||
const { getByTestId } = render(
|
||||
it("defaults to an empty chain and null host with no provider", () => {
|
||||
render(<AncestryProbe />);
|
||||
const probe = screen.getByTestId("probe");
|
||||
expect(probe.getAttribute("data-chain")).toBe("");
|
||||
expect(probe.getAttribute("data-chain-length")).toBe("0");
|
||||
expect(probe.getAttribute("data-host")).toBe("");
|
||||
});
|
||||
|
||||
it("accumulates sourcePageId into the chain across nested providers", () => {
|
||||
render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
|
||||
<PageEmbedAncestryProvider sourcePageId="b">
|
||||
<PageEmbedAncestryProvider sourcePageId="c">
|
||||
<Probe testId="leaf" />
|
||||
<AncestryProbe />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const value = read(getByTestId("leaf"));
|
||||
expect(value.chain).toEqual(["a", "b", "c"]);
|
||||
expect(value.hostPageId).toBe("host");
|
||||
const probe = screen.getByTestId("probe");
|
||||
// Chain is built outermost -> innermost.
|
||||
expect(probe.getAttribute("data-chain")).toBe("a,b,c");
|
||||
expect(probe.getAttribute("data-chain-length")).toBe("3");
|
||||
});
|
||||
|
||||
it("leaves the chain unchanged when sourcePageId is absent, still propagating the host", () => {
|
||||
const { getByTestId } = render(
|
||||
it("threads the host page id from the outermost provider down the tree", () => {
|
||||
render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host-page">
|
||||
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="ignored">
|
||||
<AncestryProbe />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("probe");
|
||||
// The first host wins (parent.hostPageId ?? hostPageId); deeper hosts are
|
||||
// ignored so the original host is preserved for self-embed detection.
|
||||
expect(probe.getAttribute("data-host")).toBe("host-page");
|
||||
});
|
||||
|
||||
it("does not add an entry to the chain when sourcePageId is missing", () => {
|
||||
render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
|
||||
<PageEmbedAncestryProvider>
|
||||
<Probe testId="leaf" />
|
||||
<PageEmbedAncestryProvider sourcePageId={null}>
|
||||
<PageEmbedAncestryProvider>
|
||||
<AncestryProbe />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const value = read(getByTestId("leaf"));
|
||||
expect(value.chain).toEqual(["a"]);
|
||||
expect(value.hostPageId).toBe("host");
|
||||
const probe = screen.getByTestId("probe");
|
||||
// null / undefined sources are pass-through: chain stays ["a"], host kept.
|
||||
expect(probe.getAttribute("data-chain")).toBe("a");
|
||||
expect(probe.getAttribute("data-host")).toBe("host");
|
||||
});
|
||||
|
||||
it("keeps the first (top-level) host even if an inner provider passes a different one", () => {
|
||||
const { getByTestId } = render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="top-host">
|
||||
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="inner-host">
|
||||
<Probe testId="leaf" />
|
||||
it("adopts a host provided only at a deeper level when the root had none", () => {
|
||||
render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a">
|
||||
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="late-host">
|
||||
<AncestryProbe />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const value = read(getByTestId("leaf"));
|
||||
expect(value.chain).toEqual(["a", "b"]);
|
||||
// Inner host is ignored: the top-level host is set once and propagated.
|
||||
expect(value.hostPageId).toBe("top-host");
|
||||
});
|
||||
|
||||
it("defaults to an empty chain and null host with no provider", () => {
|
||||
const { getByTestId } = render(<Probe testId="leaf" />);
|
||||
const value = read(getByTestId("leaf"));
|
||||
expect(value.chain).toEqual([]);
|
||||
expect(value.hostPageId).toBeNull();
|
||||
const probe = screen.getByTestId("probe");
|
||||
expect(probe.getAttribute("data-host")).toBe("late-host");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPageEmbedCycle", () => {
|
||||
it("is false when the source is not in the chain and is not the host", () => {
|
||||
expect(isPageEmbedCycle(["a", "b"], "host", "c")).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when the source is already present in the ancestor chain", () => {
|
||||
expect(isPageEmbedCycle(["a", "b", "c"], "host", "b")).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for a top-level self-embed (host === source, empty chain)", () => {
|
||||
expect(isPageEmbedCycle([], "self", "self")).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when the source equals the host even mid-chain", () => {
|
||||
expect(isPageEmbedCycle(["x"], "self", "self")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when there is no source id (nothing to embed yet)", () => {
|
||||
expect(isPageEmbedCycle(["a"], "host", null)).toBe(false);
|
||||
expect(isPageEmbedCycle([], "host", "")).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when host is null and source is not in the chain", () => {
|
||||
expect(isPageEmbedCycle(["a", "b"], null, "c")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPageEmbedTooDeep", () => {
|
||||
it("is false below the max depth", () => {
|
||||
expect(isPageEmbedTooDeep([])).toBe(false);
|
||||
expect(
|
||||
isPageEmbedTooDeep(new Array(PAGE_EMBED_MAX_DEPTH - 1).fill("x")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true once the chain length reaches the max depth", () => {
|
||||
expect(
|
||||
isPageEmbedTooDeep(new Array(PAGE_EMBED_MAX_DEPTH).fill("x")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when the chain length exceeds the max depth", () => {
|
||||
expect(
|
||||
isPageEmbedTooDeep(new Array(PAGE_EMBED_MAX_DEPTH + 3).fill("x")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("guards at exactly PAGE_EMBED_MAX_DEPTH (=5)", () => {
|
||||
// Pin the documented constant so an accidental change is caught.
|
||||
expect(PAGE_EMBED_MAX_DEPTH).toBe(5);
|
||||
expect(isPageEmbedTooDeep(["1", "2", "3", "4"])).toBe(false);
|
||||
expect(isPageEmbedTooDeep(["1", "2", "3", "4", "5"])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,3 +51,26 @@ export function PageEmbedAncestryProvider({
|
||||
export function usePageEmbedAncestry() {
|
||||
return useContext(PageEmbedAncestryContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure cycle predicate used by the page-embed node view. Returns true when the
|
||||
* source page would recurse into itself: either it is already present in the
|
||||
* ancestor chain, or it is the host page (top-level self-embed). Extracted so
|
||||
* the anti-DoS guard can be unit-tested without mounting the Tiptap NodeView.
|
||||
*/
|
||||
export function isPageEmbedCycle(
|
||||
chain: string[],
|
||||
hostPageId: string | null,
|
||||
sourcePageId: string | null,
|
||||
): boolean {
|
||||
if (!sourcePageId) return false;
|
||||
return chain.includes(sourcePageId) || hostPageId === sourcePageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure depth-limit predicate. Returns true once the ancestor chain has reached
|
||||
* the hard cap, before a deeper nested editor is mounted.
|
||||
*/
|
||||
export function isPageEmbedTooDeep(chain: string[]): boolean {
|
||||
return chain.length >= PAGE_EMBED_MAX_DEPTH;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@ export function PageEmbedLookupProvider({
|
||||
|
||||
try {
|
||||
const { items } = await lookupTemplate({ sourcePageIds: ids });
|
||||
const returned = new Set<string>();
|
||||
for (const r of items) {
|
||||
returned.add(r.sourcePageId);
|
||||
resultCacheRef.current.set(r.sourcePageId, r);
|
||||
inFlightRef.current.delete(r.sourcePageId);
|
||||
const subs = subscribersRef.current.get(r.sourcePageId);
|
||||
@@ -64,6 +66,17 @@ export function PageEmbedLookupProvider({
|
||||
}
|
||||
resolveWaiters(r.sourcePageId);
|
||||
}
|
||||
// Harden against a partial/short server response: any requested id not
|
||||
// present in `items` would otherwise stay in `inFlightRef` forever
|
||||
// (subscribe/refresh are guarded by `!inFlightRef.has(id)`) and its
|
||||
// refresh() promise would never resolve. Clear + resolve those ids,
|
||||
// mirroring the catch branch, so no id can be stranded in-flight.
|
||||
for (const id of ids) {
|
||||
if (!returned.has(id)) {
|
||||
inFlightRef.current.delete(id);
|
||||
resolveWaiters(id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Surface the failure: errors must never be swallowed silently.
|
||||
console.error("[pageEmbed] template lookup failed", err);
|
||||
|
||||
@@ -2,10 +2,9 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowsMaximize,
|
||||
IconDots,
|
||||
IconExternalLink,
|
||||
IconEyeOff,
|
||||
IconFileText,
|
||||
IconInfoCircle,
|
||||
IconRefresh,
|
||||
IconRepeat,
|
||||
@@ -138,20 +137,6 @@ function PageEmbedBody({
|
||||
<IconRefresh size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{sourceHref && (
|
||||
<Tooltip label={t("Open source page")}>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
to={sourceHref}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
style={{ textDecoration: "none", borderBottom: "none" }}
|
||||
>
|
||||
<IconExternalLink size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Menu position="bottom-end" withinPortal onChange={trackOpen}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
@@ -172,13 +157,18 @@ function PageEmbedBody({
|
||||
) : null;
|
||||
|
||||
const header =
|
||||
sourceTitle || sourceIcon ? (
|
||||
// Render the badge whenever the source resolves (sourceHref), not only when
|
||||
// it has a title/icon — the title link is now the single way to open the
|
||||
// source, so it must not disappear when title and icon are both empty.
|
||||
sourceTitle || sourceIcon || sourceHref ? (
|
||||
<div className={classes.transclusionBadge}>
|
||||
{sourceIcon ? `${sourceIcon} ` : <IconArrowsMaximize size={12} />}
|
||||
{sourceIcon ? `${sourceIcon} ` : <IconFileText size={12} />}
|
||||
{sourceHref ? (
|
||||
<Link
|
||||
to={sourceHref}
|
||||
style={{ borderBottom: "none", textDecoration: "none" }}
|
||||
title={t("Open source page")}
|
||||
aria-label={t("Open source page")}
|
||||
>
|
||||
{sourceTitle || t("Untitled")}
|
||||
</Link>
|
||||
@@ -226,7 +216,17 @@ function PageEmbedBody({
|
||||
sourcePageId={sourcePageId}
|
||||
hostPageId={hostPageId}
|
||||
>
|
||||
<PageEmbedContent content={result.content} />
|
||||
{/*
|
||||
Tiptap's EditorProvider consumes `content` only at initial mount, so a
|
||||
changed `content` prop (e.g. after Refresh re-fetches fresh content)
|
||||
would not update the read-only sub-editor. Key on the source's
|
||||
updatedAt to remount PageEmbedContent (and its inner EditorProvider)
|
||||
whenever the source page changes, applying the refreshed content.
|
||||
*/}
|
||||
<PageEmbedContent
|
||||
key={result.sourceUpdatedAt}
|
||||
content={result.content}
|
||||
/>
|
||||
</PageEmbedAncestryProvider>
|
||||
);
|
||||
} else if (embedState === "no_access") {
|
||||
|
||||
@@ -183,7 +183,8 @@
|
||||
}
|
||||
|
||||
:global(.react-renderer.node-transclusionSource.ProseMirror-selectednode),
|
||||
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode) {
|
||||
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode),
|
||||
:global(.react-renderer.node-pageEmbed.ProseMirror-selectednode) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user