mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
[codex] Add LLM Wiki plugin package to master (#5716)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The plugin system is the extension surface for optional product capabilities without baking every workflow into core. > - The LLM Wiki plugin package was reviewed in stacked PR #5592, which targeted `pap-9173-llm-wiki-rest`. > - The stack base PR #5597 merged to `master` before #5592 was merged into that branch, so the plugin package never reached `master`. > - A direct PR from `pap-9173-llm-wiki-rest` back to `master` would be noisy because that branch has diverged from current `master`. > - This pull request reapplies the reviewed `packages/plugins/plugin-llm-wiki/` package onto current `master` and updates Docker deps-stage manifest coverage. > - The branch intentionally no longer changes `pnpm-workspace.yaml` after maintainer feedback; because the new package is now a root workspace importer, the remaining integration question is how maintainers want the root lockfile handled under the current PR policy. ## What Changed - Added the LLM Wiki plugin package under `packages/plugins/plugin-llm-wiki/` from the merged PR #5592 head. - Preserved the post-review cleanup from #5592: generated design/screenshot artifacts are not committed, and `src/ui/index.tsx` / `src/wiki.ts` are small public entrypoints. - Added the new plugin package manifest to the Docker deps stage so policy can validate package manifest coverage. - Removed the earlier `pnpm-workspace.yaml` exclusion per maintainer request, so the plugin is included by the existing `packages/plugins/*` workspace glob. ## Verification Current head: - PGlite migration harness: ran migrations 001-003, verified old non-space distillation unique constraints were removed, inserted duplicate cursor and work-item keys in a second space, then reran migration 003 successfully - `node ./scripts/check-docker-deps-stage.mjs` - `git diff --check` Known current-head install result after removing the workspace exclusion: - `pnpm install --frozen-lockfile` fails because `pnpm-lock.yaml` has no importer for `packages/plugins/plugin-llm-wiki/package.json`. Previously verified on the same plugin source before the workspace-exclusion removal: - `pnpm --filter @paperclipai/plugin-sdk build` - `cd packages/plugins/plugin-llm-wiki && pnpm install --lockfile=false && pnpm test` ## Risks - The branch now includes `packages/plugins/plugin-llm-wiki` in the root workspace but does not update `pnpm-lock.yaml`. Root frozen install will fail until maintainers choose a lockfile path that fits repo policy. - Committing `pnpm-lock.yaml` directly on this PR conflicts with the current PR policy check, while excluding the package from `pnpm-workspace.yaml` was rejected in maintainer feedback. - The package includes UI code already reviewed in #5592; generated screenshot/design artifacts were intentionally removed per maintainer request, so visual review should regenerate screenshots locally if needed. - The package depends on plugin host support from #5597, which is already merged to `master`. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI GPT-5 Codex via Codex CLI, tool use and local code execution enabled; context window not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run the targeted checks listed above - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Stack context: #5592 was merged into `pap-9173-llm-wiki-rest` after #5597 had already merged that branch to `master`, so this follow-up PR is needed to carry the plugin package itself into `master`. Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
ad0bb57350
commit
508355b8fc
59 changed files with 21490 additions and 0 deletions
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { readIngestOperationIssueId, uploadIssueAttachmentFile } from "../src/ui/issue-attachments.js";
|
||||
|
||||
describe("LLM Wiki issue attachment uploads", () => {
|
||||
it("reads the ingest operation issue id from the action result", () => {
|
||||
expect(readIngestOperationIssueId({
|
||||
operation: {
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
},
|
||||
},
|
||||
})).toBe("issue-1");
|
||||
});
|
||||
|
||||
it("rejects an ingest result that cannot identify the created issue", () => {
|
||||
expect(() => readIngestOperationIssueId({ operation: { issue: null } }))
|
||||
.toThrow("did not return an issue id");
|
||||
});
|
||||
|
||||
it("uploads the original file to the created ingest task", async () => {
|
||||
const file = new File(["hello"], "source notes.md", { type: "text/markdown" });
|
||||
const calls: Array<{ input: string; init: RequestInit }> = [];
|
||||
const fetchImpl = async (input: string, init: RequestInit) => {
|
||||
calls.push({ input, init });
|
||||
return new Response(JSON.stringify({ id: "attachment-1" }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
await expect(uploadIssueAttachmentFile({
|
||||
companyId: "company 1",
|
||||
issueId: "issue/1",
|
||||
file,
|
||||
fetchImpl,
|
||||
})).resolves.toEqual({ id: "attachment-1" });
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.input).toBe("/api/companies/company%201/issues/issue%2F1/attachments");
|
||||
expect(calls[0]?.init.method).toBe("POST");
|
||||
expect(calls[0]?.init.credentials).toBe("include");
|
||||
const body = calls[0]?.init.body;
|
||||
expect(body).toBeInstanceOf(FormData);
|
||||
expect((body as FormData).get("file")).toBe(file);
|
||||
});
|
||||
|
||||
it("surfaces server upload errors", async () => {
|
||||
const fetchImpl = async () => new Response(JSON.stringify({ error: "Attachment exceeds 10 bytes" }), {
|
||||
status: 422,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
await expect(uploadIssueAttachmentFile({
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
file: new File(["hello"], "source.txt"),
|
||||
fetchImpl,
|
||||
})).rejects.toThrow("Attachment exceeds 10 bytes");
|
||||
});
|
||||
});
|
||||
3746
packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts
Normal file
3746
packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
167
packages/plugins/plugin-llm-wiki/tests/screenshots/capture.mjs
Normal file
167
packages/plugins/plugin-llm-wiki/tests/screenshots/capture.mjs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
#!/usr/bin/env node
|
||||
import { mkdirSync, copyFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve, extname } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import http from "node:http";
|
||||
import esbuild from "esbuild";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgRoot = resolve(__dirname, "..", "..");
|
||||
const outDir = resolve(pkgRoot, "dist", "screenshots");
|
||||
const screensDir = resolve(pkgRoot, "screenshots");
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
mkdirSync(screensDir, { recursive: true });
|
||||
|
||||
const entry = resolve(__dirname, "entry.tsx");
|
||||
|
||||
const repoRoot = resolve(pkgRoot, "..", "..", "..");
|
||||
const reactPath = resolve(repoRoot, "node_modules/.pnpm/react@19.2.4/node_modules/react");
|
||||
const reactDomPath = resolve(repoRoot, "node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom");
|
||||
|
||||
console.log("Bundling screenshot harness…");
|
||||
await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
outfile: resolve(outDir, "bundle.js"),
|
||||
jsx: "automatic",
|
||||
loader: { ".tsx": "tsx", ".ts": "ts" },
|
||||
define: { "process.env.NODE_ENV": '"production"' },
|
||||
logLevel: "warning",
|
||||
alias: {
|
||||
"react": reactPath,
|
||||
"react-dom": reactDomPath,
|
||||
"react-dom/client": resolve(reactDomPath, "client.js"),
|
||||
"react/jsx-runtime": resolve(reactPath, "jsx-runtime.js"),
|
||||
},
|
||||
});
|
||||
|
||||
copyFileSync(resolve(__dirname, "index.html"), resolve(outDir, "index.html"));
|
||||
|
||||
const desktopViewport = { width: 1440, height: 920 };
|
||||
const mobileViewport = { width: 390, height: 844 };
|
||||
|
||||
const desktopTargets = [
|
||||
{ slug: "01-wiki-browse", view: "wiki-sidebar", section: null },
|
||||
{ slug: "02-wiki-ingest", view: "wiki-sidebar", section: "ingest" },
|
||||
{ slug: "03-wiki-query", view: "wiki-sidebar", section: "query" },
|
||||
{ slug: "04-wiki-lint", view: "wiki-sidebar", section: "lint" },
|
||||
{ slug: "05-wiki-history", view: "wiki-sidebar", section: "history" },
|
||||
{ slug: "06-wiki-settings", view: "wiki-sidebar", section: "settings" },
|
||||
{ slug: "07-host-settings", view: "settings" },
|
||||
{ slug: "09-sidebar-link", view: "sidebar" },
|
||||
{ slug: "11-wiki-distillation-settings", view: "wiki-sidebar", section: "settings/distillation" },
|
||||
{ slug: "12-wiki-distillation-unconfigured", view: "wiki-sidebar", section: "settings/distillation", search: "unconfigured=1" },
|
||||
{ slug: "20-spaces-sidebar", view: "wiki-sidebar", section: null },
|
||||
{ slug: "21-spaces-ingest", view: "wiki-sidebar", section: "ingest" },
|
||||
{ slug: "21a-spaces-ingest-with-disclaimer", view: "wiki-sidebar", section: "ingest" },
|
||||
{ slug: "22-spaces-edit", view: "wiki-sidebar", section: "settings/spaces/team-research", scrollToText: "Paperclip ingestion" },
|
||||
{ slug: "22a-spaces-edit-default", view: "wiki-sidebar", section: "settings/spaces/default", scrollToText: "Paperclip ingestion" },
|
||||
{ slug: "23-spaces-non-default-route", view: "wiki-sidebar", section: "spaces/team-research" },
|
||||
{ slug: "24-spaces-create-modal", view: "wiki-sidebar", section: null, openCreateSpaceModal: true },
|
||||
];
|
||||
|
||||
const mobileTargets = desktopTargets
|
||||
.filter((target) => !target.openCreateSpaceModal)
|
||||
.map((target) => ({
|
||||
...target,
|
||||
slug: `mobile/${target.slug}`,
|
||||
// In the production host, the route sidebar lives in the mobile drawer.
|
||||
// The page body should therefore be checked without the desktop sidebar.
|
||||
view: target.view === "wiki-sidebar" ? "wiki" : target.view,
|
||||
viewport: mobileViewport,
|
||||
}));
|
||||
|
||||
const targets = [
|
||||
...desktopTargets.map((target) => ({ ...target, viewport: desktopViewport })),
|
||||
...mobileTargets,
|
||||
];
|
||||
|
||||
const playwrightUrl = pathToFileURL(resolve(pkgRoot, "node_modules/playwright/index.mjs")).href;
|
||||
const playwrightFallback = resolve(pkgRoot, "..", "..", "..", "node_modules", ".pnpm", "playwright@1.58.2", "node_modules", "playwright", "index.mjs");
|
||||
let playwrightModuleHref = playwrightUrl;
|
||||
if (!existsSync(resolve(pkgRoot, "node_modules/playwright/index.mjs"))) {
|
||||
if (existsSync(playwrightFallback)) {
|
||||
playwrightModuleHref = pathToFileURL(playwrightFallback).href;
|
||||
} else {
|
||||
throw new Error("Cannot locate playwright module");
|
||||
}
|
||||
}
|
||||
const { chromium } = await import(playwrightModuleHref);
|
||||
|
||||
const mimeFor = (ext) => ({
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
})[ext] ?? "application/octet-stream";
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname;
|
||||
const ext = extname(requestedPath);
|
||||
const candidate = ext ? resolve(outDir, "." + requestedPath) : resolve(outDir, "./index.html");
|
||||
try {
|
||||
const body = readFileSync(candidate);
|
||||
res.writeHead(200, { "Content-Type": mimeFor(extname(candidate)) });
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end("Not found: " + candidate);
|
||||
}
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const { port } = server.address();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: desktopViewport, deviceScaleFactor: 2 });
|
||||
const page = await context.newPage();
|
||||
page.on("console", (msg) => console.log(` [console.${msg.type()}]`, msg.text()));
|
||||
page.on("pageerror", (err) => console.error(" [pageerror]", err.message));
|
||||
|
||||
for (const target of targets) {
|
||||
const sectionPath = target.section ? `/${target.section}` : "";
|
||||
const search = target.search ? `?${target.search}` : "";
|
||||
const url = `${baseUrl}/PAP/wiki${sectionPath}${search}#${target.view}`;
|
||||
console.log(`→ rendering ${target.slug} (${url})`);
|
||||
await page.setViewportSize(target.viewport);
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(200);
|
||||
if (target.slug === "09-sidebar-link") {
|
||||
await page.addStyleTag({ content: "body { background: var(--sidebar); }" });
|
||||
}
|
||||
if (target.openCreateSpaceModal) {
|
||||
await page.evaluate(() => {
|
||||
const btn = document.querySelector('button[aria-label="Create space"]');
|
||||
if (!btn) throw new Error("Create space button not found in DOM");
|
||||
(btn).click();
|
||||
});
|
||||
await page.waitForSelector('[aria-labelledby="create-space-modal-title"]', { timeout: 5000 });
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
if (target.scrollToText) {
|
||||
await page.getByText(target.scrollToText).first().scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
const outFile = resolve(screensDir, `${target.slug}.png`);
|
||||
mkdirSync(dirname(outFile), { recursive: true });
|
||||
await page.screenshot({ path: outFile, fullPage: false });
|
||||
const horizontalOverflow = await page.evaluate(() => {
|
||||
const rootOverflow = document.documentElement.scrollWidth - document.documentElement.clientWidth;
|
||||
const bodyOverflow = document.body.scrollWidth - window.innerWidth;
|
||||
return Math.max(rootOverflow, bodyOverflow);
|
||||
});
|
||||
if (horizontalOverflow > 1) {
|
||||
throw new Error(`${target.slug} has ${horizontalOverflow}px horizontal overflow at ${target.viewport.width}px`);
|
||||
}
|
||||
console.log(` saved ${outFile}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
server.close();
|
||||
console.log("Done. Screenshots in", screensDir);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./harness.js";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) throw new Error("No #root in harness host");
|
||||
createRoot(container).render(<App />);
|
||||
1061
packages/plugins/plugin-llm-wiki/tests/screenshots/harness.tsx
Normal file
1061
packages/plugins/plugin-llm-wiki/tests/screenshots/harness.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,84 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLM Wiki UI Harness</title>
|
||||
<style>
|
||||
:root {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.145 0 0);
|
||||
}
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Minimal Tailwind utility shim — supports just the classes used by the */
|
||||
/* plugin's WikiRouteSidebar so the screenshot harness renders something */
|
||||
/* visually close to the production host (which compiles real Tailwind). */
|
||||
.w-60 { width: 15rem; }
|
||||
.h-full { height: 100%; }
|
||||
.min-h-0 { min-height: 0; }
|
||||
.border-r { border-right-width: 1px; border-right-style: solid; }
|
||||
.border-t { border-top-width: 1px; border-top-style: solid; }
|
||||
.border-border { border-color: var(--border); }
|
||||
.bg-background { background: var(--background); }
|
||||
.bg-accent { background: var(--accent); }
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-1 { flex: 1; }
|
||||
.items-center { align-items: center; }
|
||||
.gap-0\.5 { gap: 0.125rem; }
|
||||
.gap-1 { gap: 0.25rem; }
|
||||
.gap-1\.5 { gap: 0.375rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-2\.5 { gap: 0.625rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||||
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
|
||||
.py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
|
||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
||||
.shrink-0 { flex-shrink: 0; }
|
||||
.text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||||
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.text-\[11px\] { font-size: 11px; }
|
||||
.text-\[13px\] { font-size: 13px; }
|
||||
.font-medium { font-weight: 500; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.uppercase { text-transform: uppercase; }
|
||||
.tracking-normal { letter-spacing: 0; }
|
||||
.text-foreground { color: var(--foreground); }
|
||||
.text-foreground\/80 { color: color-mix(in oklab, var(--foreground) 80%, transparent); }
|
||||
.text-muted-foreground { color: var(--muted-foreground); }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rounded-md { border-radius: 0.375rem; }
|
||||
.transition-colors { transition: background-color 150ms, color 150ms; }
|
||||
.overflow-y-auto { overflow-y: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,781 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { createElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WikiPage, WikiRouteSidebar } from "../src/ui/index.js";
|
||||
|
||||
const COMPANY_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const EXPANDED_STORAGE_KEY = `paperclipai.plugin-llm-wiki:route-sidebar-expanded:v2:${COMPANY_ID}`;
|
||||
|
||||
type BridgeGlobal = typeof globalThis & {
|
||||
__paperclipPluginBridge__?: {
|
||||
sdkUi?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
type FileTreeNodeLike = {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: string;
|
||||
children?: FileTreeNodeLike[];
|
||||
};
|
||||
|
||||
type FileTreePropsLike = {
|
||||
nodes: FileTreeNodeLike[];
|
||||
selectedFile?: string | null;
|
||||
expandedPaths?: ReadonlySet<string> | readonly string[];
|
||||
onToggleDir?: (path: string) => void;
|
||||
onSelectFile?: (path: string) => void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function createFileDragEvent(
|
||||
type: string,
|
||||
options: { files?: File[]; relatedTarget?: EventTarget | null } = {},
|
||||
) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "dataTransfer", {
|
||||
value: {
|
||||
types: ["Files"],
|
||||
files: options.files ?? [],
|
||||
dropEffect: "none",
|
||||
},
|
||||
});
|
||||
Object.defineProperty(event, "relatedTarget", {
|
||||
value: options.relatedTarget ?? null,
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
function toArray(paths: FileTreePropsLike["expandedPaths"]): string[] {
|
||||
if (!paths) return [];
|
||||
return Array.isArray(paths) ? [...paths] : Array.from(paths);
|
||||
}
|
||||
|
||||
function renderTreeButtons(
|
||||
nodes: FileTreeNodeLike[],
|
||||
options: Pick<FileTreePropsLike, "onSelectFile" | "onToggleDir">,
|
||||
): ReturnType<typeof createElement>[] {
|
||||
const buttons: ReturnType<typeof createElement>[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "dir") {
|
||||
buttons.push(
|
||||
createElement("button", {
|
||||
key: node.path,
|
||||
type: "button",
|
||||
"data-toggle-dir": node.path,
|
||||
onClick: () => options.onToggleDir?.(node.path),
|
||||
}, node.name),
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
createElement("button", {
|
||||
key: node.path,
|
||||
type: "button",
|
||||
"data-select-file": node.path,
|
||||
onClick: () => options.onSelectFile?.(node.path),
|
||||
}, node.name),
|
||||
);
|
||||
}
|
||||
buttons.push(...renderTreeButtons(node.children ?? [], options));
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
describe("WikiRouteSidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let hostLocation: { pathname: string; search: string; hash: string; state?: unknown };
|
||||
let navigatedTo: { to: string; options?: unknown } | null;
|
||||
let pluginDataCalls: Array<{ key: string; params?: Record<string, unknown> }>;
|
||||
let pluginActionCalls: Array<{ key: string; params?: unknown }>;
|
||||
let spacesRefreshCount: number;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
hostLocation = {
|
||||
pathname: "/PAP/wiki/page/wiki/concepts/sidebar-navigation.md",
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
navigatedTo = null;
|
||||
pluginDataCalls = [];
|
||||
pluginActionCalls = [];
|
||||
spacesRefreshCount = 0;
|
||||
(globalThis as BridgeGlobal).__paperclipPluginBridge__ = {
|
||||
sdkUi: {
|
||||
usePluginData: (key: string, params?: Record<string, unknown>) => {
|
||||
pluginDataCalls.push({ key, params });
|
||||
if (key === "spaces") {
|
||||
return {
|
||||
data: {
|
||||
spaces: [
|
||||
{
|
||||
id: "space-default",
|
||||
companyId: COMPANY_ID,
|
||||
wikiId: "default",
|
||||
slug: "default",
|
||||
displayName: "default",
|
||||
spaceType: "managed",
|
||||
folderMode: "managed_subfolder",
|
||||
rootFolderKey: "wiki-root",
|
||||
pathPrefix: null,
|
||||
configuredRootPath: null,
|
||||
accessScope: "shared",
|
||||
ownerUserId: null,
|
||||
ownerAgentId: null,
|
||||
teamKey: null,
|
||||
settings: {},
|
||||
status: "active",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
{
|
||||
id: "space-engineering",
|
||||
companyId: COMPANY_ID,
|
||||
wikiId: "default",
|
||||
slug: "engineering",
|
||||
displayName: "Engineering",
|
||||
spaceType: "managed",
|
||||
folderMode: "managed_subfolder",
|
||||
rootFolderKey: "wiki-root",
|
||||
pathPrefix: "spaces/engineering",
|
||||
configuredRootPath: null,
|
||||
accessScope: "shared",
|
||||
ownerUserId: null,
|
||||
ownerAgentId: null,
|
||||
teamKey: null,
|
||||
settings: {},
|
||||
status: "active",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
{
|
||||
id: "space-archived",
|
||||
companyId: COMPANY_ID,
|
||||
wikiId: "default",
|
||||
slug: "qa-team-lock",
|
||||
displayName: "QA Team Lock",
|
||||
spaceType: "managed",
|
||||
folderMode: "managed_subfolder",
|
||||
rootFolderKey: "wiki-root",
|
||||
pathPrefix: "spaces/qa-team-lock",
|
||||
configuredRootPath: null,
|
||||
accessScope: "shared",
|
||||
ownerUserId: null,
|
||||
ownerAgentId: null,
|
||||
teamKey: null,
|
||||
settings: {},
|
||||
status: "archived",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => {
|
||||
spacesRefreshCount += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (key !== "pages") return { data: null, loading: false, error: null, refresh: () => undefined };
|
||||
return {
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
path: "wiki/concepts/sidebar-navigation.md",
|
||||
title: "Sidebar navigation",
|
||||
pageType: "concepts",
|
||||
backlinkCount: 0,
|
||||
sourceCount: 0,
|
||||
contentHash: "abc123",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
sources: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => undefined,
|
||||
};
|
||||
},
|
||||
usePluginAction: (key: string) => async (params?: unknown) => {
|
||||
pluginActionCalls.push({ key, params });
|
||||
return {};
|
||||
},
|
||||
usePluginToast: () => () => undefined,
|
||||
useHostLocation: () => hostLocation,
|
||||
useHostNavigation: () => ({
|
||||
resolveHref: (to: string) => `/PAP${to.startsWith("/") ? to : `/${to}`}`,
|
||||
navigate: (to: string, options?: unknown) => {
|
||||
navigatedTo = { to, options };
|
||||
},
|
||||
linkProps: (to: string) => ({
|
||||
href: `/PAP${to.startsWith("/") ? to : `/${to}`}`,
|
||||
onClick: () => undefined,
|
||||
}),
|
||||
}),
|
||||
FileTree: (props: FileTreePropsLike) => createElement(
|
||||
"div",
|
||||
{
|
||||
role: "tree",
|
||||
"data-selected-file": props.selectedFile ?? "",
|
||||
"data-expanded-paths": toArray(props.expandedPaths).sort().join("|"),
|
||||
},
|
||||
renderTreeButtons(props.nodes, props),
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
window.localStorage.clear();
|
||||
delete (globalThis as BridgeGlobal).__paperclipPluginBridge__;
|
||||
});
|
||||
|
||||
it("defaults wiki categories open so local files are visible", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const tree = container.querySelector("[role='tree']") as HTMLElement;
|
||||
expect(tree.dataset.expandedPaths?.split("|")).toEqual([
|
||||
"wiki",
|
||||
"wiki/concepts",
|
||||
"wiki/entities",
|
||||
"wiki/projects",
|
||||
"wiki/sources",
|
||||
"wiki/synthesis",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders Ask before Add Content in the primary sidebar tools", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const primaryNavText = container.querySelector("nav[aria-label='Wiki primary']")?.textContent ?? "";
|
||||
expect(primaryNavText.indexOf("Ask")).toBeLessThan(primaryNavText.indexOf("Add Content"));
|
||||
});
|
||||
|
||||
it("collapses and expands the active space tree from the space row", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
expect(container.querySelector("[role='tree']")).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
(container.querySelector("[aria-label='Collapse default space']") as HTMLElement).click();
|
||||
});
|
||||
|
||||
expect(container.querySelector("[role='tree']")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
(container.querySelector("[aria-label='Expand default space']") as HTMLElement).click();
|
||||
});
|
||||
|
||||
expect(container.querySelector("[role='tree']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("omits redundant shared badges beside space names", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("shared");
|
||||
});
|
||||
|
||||
it("hides archived spaces from the sidebar", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Engineering");
|
||||
expect(container.textContent).not.toContain("QA Team Lock");
|
||||
expect(pluginDataCalls).not.toContainEqual({
|
||||
key: "pages",
|
||||
params: { companyId: COMPANY_ID, includeRaw: true, spaceSlug: "qa-team-lock" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes and leaves an archived active space after sidebar archive", async () => {
|
||||
hostLocation = {
|
||||
pathname: "/PAP/wiki/spaces/engineering",
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
(container.querySelector("[aria-label='Engineering space menu']") as HTMLButtonElement).click();
|
||||
});
|
||||
const archiveButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Archive space"));
|
||||
|
||||
await act(async () => {
|
||||
archiveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(pluginActionCalls).toContainEqual({
|
||||
key: "archive-space",
|
||||
params: { companyId: COMPANY_ID, spaceSlug: "engineering" },
|
||||
});
|
||||
expect(spacesRefreshCount).toBe(1);
|
||||
expect(navigatedTo).toEqual({ to: "/wiki", options: undefined });
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it("persists folder expansion client-side", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
(container.querySelector("[data-toggle-dir='raw']") as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
// Toggled paths are stored under the active space slug ("default::") so
|
||||
// each space remembers its own expansion state. Legacy entries written
|
||||
// before the spaces refactor stay un-prefixed and still resolve to default.
|
||||
expect(JSON.parse(window.localStorage.getItem(EXPANDED_STORAGE_KEY) ?? "[]")).toEqual([
|
||||
"default::raw",
|
||||
"wiki",
|
||||
"wiki/concepts",
|
||||
"wiki/entities",
|
||||
"wiki/projects",
|
||||
"wiki/sources",
|
||||
"wiki/synthesis",
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const tree = container.querySelector("[role='tree']") as HTMLElement;
|
||||
expect(tree.dataset.expandedPaths).toBe("raw|wiki|wiki/concepts|wiki/entities|wiki/projects|wiki/sources|wiki/synthesis");
|
||||
});
|
||||
|
||||
it("does not select a wiki-link destination from the route", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const tree = () => container.querySelector("[role='tree']") as HTMLElement;
|
||||
expect(tree().dataset.selectedFile).toBe("");
|
||||
});
|
||||
|
||||
it("keeps sidebar tree selection scoped to sidebar navigation", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const tree = () => container.querySelector("[role='tree']") as HTMLElement;
|
||||
|
||||
act(() => {
|
||||
(container.querySelector("[data-select-file='wiki/concepts/sidebar-navigation.md']") as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
expect(navigatedTo).toEqual({
|
||||
to: "/wiki/page/wiki/concepts/sidebar-navigation.md",
|
||||
options: { state: { paperclipWikiSidebarTreePath: "wiki/concepts/sidebar-navigation.md" } },
|
||||
});
|
||||
// The default space stays the active space, so its tree is rendered in the
|
||||
// sidebar; non-default spaces only render their tree once activated.
|
||||
expect(tree().dataset.selectedFile).toBe("wiki/concepts/sidebar-navigation.md");
|
||||
|
||||
hostLocation = {
|
||||
pathname: "/PAP/wiki/page/wiki/entities/paperclip.md",
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
expect(tree().dataset.selectedFile).toBe("wiki/concepts/sidebar-navigation.md");
|
||||
|
||||
act(() => {
|
||||
(container.querySelector("[data-toggle-dir='wiki/concepts']") as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
expect(tree().dataset.selectedFile).toBe("wiki/concepts/sidebar-navigation.md");
|
||||
expect(tree().dataset.expandedPaths?.split("|")).not.toContain("wiki/concepts");
|
||||
});
|
||||
|
||||
it("warms inactive space pages so sidebar space switches have data ready", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
expect(pluginDataCalls).toContainEqual({
|
||||
key: "pages",
|
||||
params: { companyId: COMPANY_ID, includeRaw: true, spaceSlug: "engineering" },
|
||||
});
|
||||
expect(pluginDataCalls).toContainEqual({
|
||||
key: "page-content",
|
||||
params: { companyId: COMPANY_ID, path: "wiki/concepts/sidebar-navigation.md", spaceSlug: "engineering" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WikiPage", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let consoleError: ReturnType<typeof vi.spyOn>;
|
||||
let hostLocation: { pathname: string; search: string; hash: string };
|
||||
let navigatedTo: string | null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
hostLocation = {
|
||||
pathname: "/PAP/wiki/page/wiki/projects/control-plane/index.md",
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
navigatedTo = null;
|
||||
(globalThis as BridgeGlobal).__paperclipPluginBridge__ = {
|
||||
sdkUi: {
|
||||
usePluginData: (key: string) => {
|
||||
if (key === "overview") {
|
||||
return {
|
||||
data: { folder: { healthy: true }, wikiId: "default" },
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => undefined,
|
||||
};
|
||||
}
|
||||
if (key === "spaces") {
|
||||
return {
|
||||
data: {
|
||||
spaces: [
|
||||
{
|
||||
id: "space-default",
|
||||
companyId: COMPANY_ID,
|
||||
wikiId: "default",
|
||||
slug: "default",
|
||||
displayName: "default",
|
||||
spaceType: "managed",
|
||||
folderMode: "managed_subfolder",
|
||||
rootFolderKey: "wiki-root",
|
||||
pathPrefix: null,
|
||||
configuredRootPath: null,
|
||||
accessScope: "shared",
|
||||
ownerUserId: null,
|
||||
ownerAgentId: null,
|
||||
teamKey: null,
|
||||
settings: {},
|
||||
status: "active",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => undefined,
|
||||
};
|
||||
}
|
||||
if (key === "settings") {
|
||||
return {
|
||||
data: {
|
||||
folder: {
|
||||
configured: true,
|
||||
path: "/tmp/company-wiki",
|
||||
realPath: "/tmp/company-wiki",
|
||||
access: "readWrite",
|
||||
readable: true,
|
||||
writable: true,
|
||||
requiredDirectories: [],
|
||||
requiredFiles: [],
|
||||
missingDirectories: [],
|
||||
missingFiles: [],
|
||||
healthy: true,
|
||||
problems: [],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
managedAgent: {
|
||||
status: "resolved",
|
||||
source: "managed",
|
||||
agentId: "agent-1",
|
||||
resourceKey: "wiki-maintainer",
|
||||
details: { name: "Wiki Maintainer", status: "idle", adapterType: "claude_local", icon: "book-open", urlKey: "wiki-maintainer" },
|
||||
},
|
||||
managedProject: {
|
||||
status: "resolved",
|
||||
source: "managed",
|
||||
projectId: "project-1",
|
||||
resourceKey: "llm-wiki",
|
||||
details: { name: "LLM Wiki", status: "in_progress" },
|
||||
},
|
||||
managedSkills: [],
|
||||
managedRoutines: [],
|
||||
eventIngestion: {
|
||||
enabled: false,
|
||||
sources: { issues: false, comments: false, documents: false },
|
||||
wikiId: "default",
|
||||
maxCharacters: 12000,
|
||||
},
|
||||
agentOptions: [{ id: "agent-1", name: "Wiki Maintainer", status: "idle", icon: "book-open", urlKey: "wiki-maintainer" }],
|
||||
projectOptions: [{ id: "project-1", name: "LLM Wiki", status: "in_progress", color: "#2563eb" }],
|
||||
capabilities: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => undefined,
|
||||
};
|
||||
}
|
||||
if (key === "pages") {
|
||||
return {
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
path: "wiki/projects/control-plane/index.md",
|
||||
title: "Control plane",
|
||||
pageType: "projects",
|
||||
backlinkCount: 0,
|
||||
sourceCount: 1,
|
||||
contentHash: "abc123",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
sources: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => undefined,
|
||||
};
|
||||
}
|
||||
if (key === "page-content") {
|
||||
return {
|
||||
data: {
|
||||
wikiId: "default",
|
||||
path: "wiki/projects/control-plane/index.md",
|
||||
contents: "# Control plane\n\nCurrent project state.",
|
||||
title: "Control plane",
|
||||
pageType: "projects",
|
||||
backlinks: [],
|
||||
sourceRefs: [
|
||||
{
|
||||
kind: "issue",
|
||||
title: "Distillation kickoff",
|
||||
issueId: "issue-1",
|
||||
projectId: "project-1",
|
||||
updatedAt: "2026-05-04T15:01:00Z",
|
||||
issueIdentifier: "PAP-3416",
|
||||
},
|
||||
],
|
||||
updatedAt: "2026-05-04T15:01:00Z",
|
||||
hash: "def456",
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => undefined,
|
||||
};
|
||||
}
|
||||
if (key === "distillation-page-provenance") {
|
||||
return { data: null, loading: false, error: null, refresh: () => undefined };
|
||||
}
|
||||
return { data: null, loading: false, error: null, refresh: () => undefined };
|
||||
},
|
||||
usePluginAction: () => async () => ({}),
|
||||
usePluginToast: () => () => undefined,
|
||||
useHostLocation: () => hostLocation,
|
||||
useHostNavigation: () => ({
|
||||
resolveHref: (to: string) => `/PAP${to.startsWith("/") ? to : `/${to}`}`,
|
||||
navigate: (to: string) => {
|
||||
navigatedTo = to;
|
||||
},
|
||||
linkProps: (to: string) => ({
|
||||
href: `/PAP${to.startsWith("/") ? to : `/${to}`}`,
|
||||
onClick: () => undefined,
|
||||
}),
|
||||
}),
|
||||
MarkdownBlock: ({ content }: { content: string }) => createElement("div", {}, content),
|
||||
MarkdownEditor: ({ value }: { value: string }) => createElement("textarea", { value, readOnly: true }),
|
||||
AssigneePicker: () => createElement("div", { "data-testid": "assignee-picker" }),
|
||||
ProjectPicker: () => createElement("div", { "data-testid": "project-picker" }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
consoleError.mockRestore();
|
||||
delete (globalThis as BridgeGlobal).__paperclipPluginBridge__;
|
||||
});
|
||||
|
||||
it("renders structured Paperclip source refs as text", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiPage, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("PAP-3416 issue - Distillation kickoff");
|
||||
const consoleOutput = consoleError.mock.calls.flat().join("\n");
|
||||
expect(consoleOutput).not.toContain("Objects are not valid as a React child");
|
||||
expect(consoleOutput).not.toContain("Each child in a list should have a unique \"key\" prop");
|
||||
});
|
||||
|
||||
it("prioritizes file drop on the ingest page without recent ingest or cost copy", () => {
|
||||
hostLocation = {
|
||||
pathname: "/PAP/wiki/ingest",
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(WikiPage, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Drop files anywhere on this page");
|
||||
expect(text).not.toContain("Recent ingests");
|
||||
expect(text).not.toContain("Why does this take a moment?");
|
||||
expect(text).not.toContain("est. cost");
|
||||
|
||||
const separatorText = container.querySelector("[data-testid='llm-wiki-ingest-manual-separator']")?.textContent ?? "";
|
||||
expect(separatorText).toBe("or");
|
||||
expect(text.indexOf("Drop files anywhere on this page")).toBeLessThan(text.indexOf("Source title"));
|
||||
expect(text.indexOf("Source title")).toBeLessThan(text.indexOf("URL"));
|
||||
expect(text.indexOf("URL")).toBeLessThan(text.indexOf("Paste markdown / text"));
|
||||
});
|
||||
|
||||
it("closes the page drop overlay when a file drag leaves without dropping files", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiPage, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const page = container.querySelector("main") as HTMLElement;
|
||||
act(() => {
|
||||
page.dispatchEvent(createFileDragEvent("dragenter"));
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-testid='llm-wiki-page-drop-overlay']")).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
page.dispatchEvent(createFileDragEvent("dragleave"));
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-testid='llm-wiki-page-drop-overlay']")).toBeNull();
|
||||
expect(container.querySelector("[data-testid='llm-wiki-ingest-modal']")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps staged dropped files in the ingest modal after the drop overlay clears", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiPage, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const page = container.querySelector("main") as HTMLElement;
|
||||
const file = new File(["source notes"], "source-notes.md", { type: "text/markdown" });
|
||||
|
||||
act(() => {
|
||||
page.dispatchEvent(createFileDragEvent("dragenter"));
|
||||
page.dispatchEvent(createFileDragEvent("drop", { files: [file] }));
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-testid='llm-wiki-page-drop-overlay']")).toBeNull();
|
||||
expect(container.querySelector("[data-testid='llm-wiki-ingest-modal']")).not.toBeNull();
|
||||
expect(container.textContent).toContain("source-notes.md");
|
||||
});
|
||||
|
||||
it("lets users close the page drop overlay directly", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiPage, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const page = container.querySelector("main") as HTMLElement;
|
||||
act(() => {
|
||||
page.dispatchEvent(createFileDragEvent("dragenter"));
|
||||
});
|
||||
|
||||
const closeButton = container.querySelector("[aria-label='Close ingest drop overlay']") as HTMLButtonElement;
|
||||
expect(closeButton).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
closeButton.click();
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-testid='llm-wiki-page-drop-overlay']")).toBeNull();
|
||||
});
|
||||
|
||||
it("navigates settings tabs to their URL subpaths", () => {
|
||||
hostLocation = {
|
||||
pathname: "/PAP/wiki/settings",
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(WikiPage, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
(Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Distillation")) as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
expect(navigatedTo).toBe("/wiki/settings/distillation");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue