Merge remote-tracking branch 'origin/develop' into work/T-659-console-history-pagination
This commit is contained in:
@@ -96,6 +96,7 @@ Use semantic tokens from `app.css`. Do not introduce component-local color, spac
|
||||
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
|
||||
- `src/lib/workspace/header/`: Header frame, context, and overrides.
|
||||
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
|
||||
- `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers.
|
||||
- `src/lib/workspace/ui/`: generic presentation components such as `Tooltip`, `Bevel`, and `BevelLine`.
|
||||
- `src/routes/design-lab/workspace-web-ux/`: static Design Language showroom.
|
||||
|
||||
|
||||
@@ -20,13 +20,9 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import SvelteMarkdown, {
|
||||
buildUnsupportedHTML,
|
||||
type Renderers,
|
||||
type SvelteMarkdownProps,
|
||||
} from "@humanspeak/svelte-markdown";
|
||||
import type { Renderers } from "@humanspeak/svelte-markdown";
|
||||
import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki";
|
||||
import MarkdownLink from "$lib/workspace/console/MarkdownLink.svelte";
|
||||
import SafeMarkdown from "$lib/workspace/markdown/SafeMarkdown.svelte";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
@@ -36,24 +32,15 @@
|
||||
|
||||
let { text, streamId = "static", class: className = "" }: Props = $props();
|
||||
|
||||
const options = {
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
} satisfies NonNullable<SvelteMarkdownProps["options"]>;
|
||||
|
||||
const renderers = {
|
||||
code: ShikiCode,
|
||||
html: buildUnsupportedHTML(),
|
||||
link: MarkdownLink,
|
||||
} satisfies Partial<Renderers>;
|
||||
</script>
|
||||
|
||||
<div class={`rich-markdown ${className}`}>
|
||||
<SvelteMarkdown source={text} {streamId} {options} {renderers} streaming />
|
||||
</div>
|
||||
<SafeMarkdown {text} {streamId} class={`rich-markdown ${className}`} {renderers} streaming />
|
||||
|
||||
<style>
|
||||
.rich-markdown {
|
||||
:global(.rich-markdown) {
|
||||
color: inherit;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@@ -496,11 +496,13 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
assert(
|
||||
memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") &&
|
||||
memoryDocumentLoad.includes('"/memory"') &&
|
||||
memoryDocumentPage.includes("Memory Document") &&
|
||||
memoryDocumentPage.includes("This view is read-only") &&
|
||||
memoryDocumentPage.includes("DocumentMarkdown from") &&
|
||||
memoryDocumentPage.includes("Read-only") &&
|
||||
memoryDocumentPage.includes("data.memory.data.body_md") &&
|
||||
memoryDocumentPage.includes("data.memory.data.updated_at"),
|
||||
"Memory Document page should read the scoped API and expose the durable document without mutation controls",
|
||||
memoryDocumentPage.includes("data.memory.data.updated_at") &&
|
||||
memoryDocumentPage.includes('role="alert"') &&
|
||||
!memoryDocumentPage.includes('<pre class="memory-document-body"'),
|
||||
"Memory Document page should read the scoped API and expose safe Markdown states without mutation controls",
|
||||
);
|
||||
assert(
|
||||
memoryStagingLoad.includes("workspaceApiPath(params.workspaceId") &&
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
lang = "",
|
||||
text,
|
||||
}: {
|
||||
lang?: string;
|
||||
text: string;
|
||||
codeBlockStyle?: "indented";
|
||||
} = $props();
|
||||
|
||||
const label = $derived(lang ? `${lang} code block` : "Code block");
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (horizontal scroll regions must be keyboard-focusable) -->
|
||||
<div class="markdown-code-region" role="region" aria-label={label} tabindex="0">
|
||||
<pre><code>{text}</code></pre>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import DocumentMarkdown from "./DocumentMarkdown.svelte";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("renders document Markdown semantics through the shared safe policy", async () => {
|
||||
const source = [
|
||||
"# Memory heading",
|
||||
"",
|
||||
"##### Deep heading",
|
||||
"",
|
||||
"###### Deepest heading",
|
||||
"",
|
||||
"A paragraph with **strong context** and `inline-code`.",
|
||||
"",
|
||||
"- first item",
|
||||
"- second item",
|
||||
"",
|
||||
"> durable quote",
|
||||
"",
|
||||
"| Decision | Owner |",
|
||||
"| --- | --- |",
|
||||
"| Keep the document safe | Workspace |",
|
||||
"",
|
||||
"```rust",
|
||||
'fn main() { println!("safe"); }',
|
||||
"```",
|
||||
"",
|
||||
"[safe](https://example.com) [script](javascript:alert(1)) [relative](/internal)",
|
||||
"",
|
||||
'<img src="x" onerror="alert(1)"><script>alert(1)</script>',
|
||||
].join("\n");
|
||||
const { container } = render(DocumentMarkdown, { text: source });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Memory heading");
|
||||
expect(container.querySelector("table")).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(container.querySelector("h5")?.textContent).toBe("Deep heading");
|
||||
expect(container.querySelector("h6")?.textContent).toBe("Deepest heading");
|
||||
expect(container.querySelector("strong")?.textContent).toBe("strong context");
|
||||
expect(container.querySelectorAll("li")).toHaveLength(2);
|
||||
expect(container.querySelector("blockquote")?.textContent).toContain(
|
||||
"durable quote",
|
||||
);
|
||||
expect(container.querySelector('a[href="https://example.com"]')).not
|
||||
.toBeNull();
|
||||
expect(container.querySelectorAll("a")).toHaveLength(1);
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
|
||||
const tableRegion = container.querySelector<HTMLElement>(
|
||||
'[role="region"][aria-label="Markdown table"]',
|
||||
);
|
||||
const codeRegion = container.querySelector<HTMLElement>(
|
||||
'[role="region"][aria-label="rust code block"]',
|
||||
);
|
||||
expect(tableRegion?.tabIndex).toBe(0);
|
||||
expect(codeRegion?.tabIndex).toBe(0);
|
||||
expect(codeRegion?.querySelector("pre > code")?.textContent).toContain(
|
||||
"println!",
|
||||
);
|
||||
});
|
||||
|
||||
test("wraps long prose and identifiers without turning them into document-level controls", async () => {
|
||||
const longIdentifier = "memory-identifier-".repeat(40);
|
||||
const { container } = render(DocumentMarkdown, {
|
||||
text:
|
||||
`Long value: ${longIdentifier}\n\n<details open><summary>unsafe HTML</summary></details>`,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.textContent).toContain(longIdentifier));
|
||||
expect(container.querySelector("details")).toBeNull();
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import type { Renderers } from "@humanspeak/svelte-markdown";
|
||||
import DocumentCodeBlock from "./DocumentCodeBlock.svelte";
|
||||
import MarkdownTable from "./MarkdownTable.svelte";
|
||||
import SafeMarkdown from "./SafeMarkdown.svelte";
|
||||
|
||||
let { text }: { text: string } = $props();
|
||||
|
||||
const renderers = {
|
||||
code: DocumentCodeBlock,
|
||||
table: MarkdownTable,
|
||||
} satisfies Partial<Renderers>;
|
||||
</script>
|
||||
|
||||
<SafeMarkdown {text} class="document-markdown" {renderers} />
|
||||
|
||||
<style>
|
||||
:global(.document-markdown) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: var(--text);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:global(.document-markdown > :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:global(.document-markdown > :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.document-markdown h1) {
|
||||
margin: var(--space-6) 0 var(--space-4);
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-title);
|
||||
line-height: var(--line-height-title);
|
||||
}
|
||||
|
||||
:global(.document-markdown h2),
|
||||
:global(.document-markdown h3),
|
||||
:global(.document-markdown h4),
|
||||
:global(.document-markdown h5),
|
||||
:global(.document-markdown h6) {
|
||||
margin: var(--space-6) 0 var(--space-2);
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
|
||||
:global(.document-markdown h2) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:global(.document-markdown h3),
|
||||
:global(.document-markdown h4),
|
||||
:global(.document-markdown h5),
|
||||
:global(.document-markdown h6) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.document-markdown p),
|
||||
:global(.document-markdown ul),
|
||||
:global(.document-markdown ol),
|
||||
:global(.document-markdown blockquote),
|
||||
:global(.document-markdown .markdown-code-region),
|
||||
:global(.document-markdown .markdown-table-region) {
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
|
||||
:global(.document-markdown ul),
|
||||
:global(.document-markdown ol) {
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
:global(.document-markdown li + li) {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
:global(.document-markdown blockquote) {
|
||||
margin-left: 0;
|
||||
padding-left: var(--space-4);
|
||||
border-left: 2px solid var(--line-strong);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
:global(.document-markdown a) {
|
||||
color: var(--accent);
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
|
||||
:global(.document-markdown :not(pre) > code) {
|
||||
padding: 0 var(--space-1);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--code);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-code-region),
|
||||
:global(.document-markdown .markdown-table-region) {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-code-region) {
|
||||
background: var(--bg-raised);
|
||||
color: var(--code);
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-code-region pre) {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-table-region table) {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-table-region th),
|
||||
:global(.document-markdown .markdown-table-region td) {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-table-region th) {
|
||||
color: var(--text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.document-markdown hr) {
|
||||
margin: var(--space-6) 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { children }: { children?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (horizontal scroll regions must be keyboard-focusable) -->
|
||||
<div class="markdown-table-region" role="region" aria-label="Markdown table" tabindex="0">
|
||||
<table>{@render children?.()}</table>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import SvelteMarkdown, {
|
||||
buildUnsupportedHTML,
|
||||
type Renderers,
|
||||
type SvelteMarkdownProps,
|
||||
} from "@humanspeak/svelte-markdown";
|
||||
import MarkdownLink from "$lib/workspace/markdown/MarkdownLink.svelte";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
streamId?: string | number;
|
||||
class?: string;
|
||||
streaming?: boolean;
|
||||
renderers?: Partial<Renderers>;
|
||||
};
|
||||
|
||||
let {
|
||||
text,
|
||||
streamId = "static",
|
||||
class: className = "",
|
||||
streaming = false,
|
||||
renderers = {},
|
||||
}: Props = $props();
|
||||
|
||||
const options = {
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
} satisfies NonNullable<SvelteMarkdownProps["options"]>;
|
||||
|
||||
const safeRenderers = $derived({
|
||||
...renderers,
|
||||
html: buildUnsupportedHTML(),
|
||||
link: MarkdownLink,
|
||||
} satisfies Partial<Renderers>);
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
<SvelteMarkdown source={text} {streamId} {options} renderers={safeRenderers} {streaming} />
|
||||
</div>
|
||||
@@ -1,123 +1,165 @@
|
||||
<script lang="ts">
|
||||
import DocumentMarkdown from '$lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate } from '$lib/workspace/api/http';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const lineCount = $derived(data.memory.data?.body_md.split('\n').length ?? 0);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Memory Document · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Memory Document" />
|
||||
<title>Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Memory document" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="card memory-document-card">
|
||||
<div class="detail-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Workspace memory</p>
|
||||
<h2>Memory Document</h2>
|
||||
</div>
|
||||
{#if data.memory.data}
|
||||
<span>{data.memory.data.bytes} bytes</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="section-note">
|
||||
Durable workspace Memory as a single Markdown document. This view is read-only.
|
||||
</p>
|
||||
|
||||
<section class="memory-document-page" aria-label="Memory document">
|
||||
{#if data.memory.data}
|
||||
<div class="memory-document-summary" aria-label="Memory document summary">
|
||||
<div>
|
||||
<span>Updated</span>
|
||||
<strong>{formatDate(data.memory.data.updated_at)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Created</span>
|
||||
<strong>{formatDate(data.memory.data.created_at)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Lines</span>
|
||||
<strong>{lineCount}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Source</span>
|
||||
<strong>{data.memory.data.record_source}</strong>
|
||||
</div>
|
||||
<div class="memory-document-meta">
|
||||
<p>
|
||||
<span>Read-only</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Updated <time datetime={data.memory.data.updated_at}>{formatDate(data.memory.data.updated_at)}</time></span>
|
||||
</p>
|
||||
<details>
|
||||
<summary>Document details</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd><time datetime={data.memory.data.created_at}>{formatDate(data.memory.data.created_at)}</time></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{data.memory.data.bytes} bytes</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd><code>{data.memory.data.record_source}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{#if data.memory.data.body_md.trim().length === 0}
|
||||
<p>No Memory document content is present.</p>
|
||||
<div class="memory-document-state" role="status">
|
||||
<strong>Memory document is empty.</strong>
|
||||
<p>Durable Workspace context will appear here when it is available.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<pre class="memory-document-body">{data.memory.data.body_md}</pre>
|
||||
<article class="memory-document-content" aria-label="Memory document content">
|
||||
<DocumentMarkdown text={data.memory.data.body_md} />
|
||||
</article>
|
||||
{/if}
|
||||
{:else if data.memory.error}
|
||||
<p class="error">{data.memory.error}</p>
|
||||
<div class="memory-document-state memory-document-error">
|
||||
<p role="alert"><strong>Memory document unavailable.</strong> {data.memory.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/w/{data.workspaceId}/memory</code>…</p>
|
||||
<div class="memory-document-state" role="status">
|
||||
<p>Memory document data is unavailable.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.memory-document-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memory-document-summary {
|
||||
.memory-document-page {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-5);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 78rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.memory-document-summary div {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--bg-raised);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.memory-document-summary span {
|
||||
.memory-document-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.memory-document-summary strong {
|
||||
.memory-document-meta > p {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.memory-document-meta details {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.memory-document-meta summary {
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-document-meta dl {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: min(24rem, calc(100vw - (2 * var(--space-4))));
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: var(--space-3) 0 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.memory-document-meta dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.memory-document-meta dt {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.memory-document-meta dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
display: block;
|
||||
font-size: var(--font-size-compact);
|
||||
margin-top: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-document-body {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.9rem;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
margin: 1rem 0 0;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
white-space: pre-wrap;
|
||||
.memory-document-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.memory-document-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
.memory-document-state {
|
||||
padding-block: var(--space-5);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-document-state strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.memory-document-state p {
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.memory-document-state > p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.memory-document-error,
|
||||
.memory-document-error strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.memory-document-meta {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.memory-document-summary {
|
||||
grid-template-columns: 1fr;
|
||||
.memory-document-meta details {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.memory-document-meta dl {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import MemoryPage from "./+page.svelte";
|
||||
import type { PageProps } from "./$types";
|
||||
|
||||
function pageProps(memory: {
|
||||
data: {
|
||||
body_md: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bytes: number;
|
||||
record_source: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}): PageProps {
|
||||
return {
|
||||
data: { workspaceId: "workspace-1", memory },
|
||||
params: { workspaceId: "workspace-1" },
|
||||
} as unknown as PageProps;
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("makes the Markdown document primary and keeps technical metadata in disclosure", async () => {
|
||||
const { container } = render(
|
||||
MemoryPage,
|
||||
pageProps({
|
||||
data: {
|
||||
body_md: "# Durable context\n\nRead the **current** document.",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: 48,
|
||||
record_source: "sqlite_workspace_authority.memory_document",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Durable context", level: 1 })).not.toBeNull();
|
||||
});
|
||||
expect(screen.getByText("Read-only")).not.toBeNull();
|
||||
expect(screen.getByText("Document details")).not.toBeNull();
|
||||
expect(container.querySelector(".memory-document-page")?.classList.contains("card")).toBe(false);
|
||||
expect(screen.queryByRole("heading", { name: "Memory Document" })).toBeNull();
|
||||
expect(screen.getByRole("article", { name: "Memory document content" })).not.toBeNull();
|
||||
});
|
||||
|
||||
test("distinguishes an empty document from request failure and unavailable data", async () => {
|
||||
const view = render(
|
||||
MemoryPage,
|
||||
pageProps({
|
||||
data: {
|
||||
body_md: " \n",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: 3,
|
||||
record_source: "fixture",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByRole("status").textContent).toContain("Memory document is empty.");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
|
||||
await view.rerender(pageProps({ data: null, error: "Memory API request failed" }));
|
||||
expect(screen.getByRole("alert").textContent).toContain("Memory document unavailable.");
|
||||
expect(screen.getByRole("alert").textContent).toContain("Memory API request failed");
|
||||
expect(screen.queryByText("Memory document is empty.")).toBeNull();
|
||||
|
||||
await view.rerender(pageProps({ data: null, error: null }));
|
||||
expect(screen.getByRole("status").textContent).toBe("Memory document data is unavailable.");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
Reference in New Issue
Block a user