dsh-better-display 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.en.md +4 -2
- package/README.md +4 -2
- package/compat/harness-rc2.json +59 -0
- package/docs/official-rendering-bridge.md +65 -0
- package/lib/client.js +858 -456
- package/lib/client.js.map +1 -1
- package/lib/types/client/Blocks.d.ts +2 -1
- package/lib/types/client/Blocks.d.ts.map +1 -1
- package/lib/types/client/Blocks.js +2 -2
- package/lib/types/client/Blocks.js.map +1 -1
- package/lib/types/client/OfficialContent.d.ts +25 -0
- package/lib/types/client/OfficialContent.d.ts.map +1 -0
- package/lib/types/client/OfficialContent.js +42 -0
- package/lib/types/client/OfficialContent.js.map +1 -0
- package/lib/types/client/Reader.d.ts.map +1 -1
- package/lib/types/client/Reader.js +38 -6
- package/lib/types/client/Reader.js.map +1 -1
- package/lib/types/client/ToolActivity.d.ts.map +1 -1
- package/lib/types/client/ToolActivity.js +21 -17
- package/lib/types/client/ToolActivity.js.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +23 -33
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/markdown/MarkdownText.d.ts +3 -1
- package/lib/types/client/markdown/MarkdownText.d.ts.map +1 -1
- package/lib/types/client/markdown/MarkdownText.js +14 -7
- package/lib/types/client/markdown/MarkdownText.js.map +1 -1
- package/lib/types/client/markdown/render.d.ts +2 -0
- package/lib/types/client/markdown/render.d.ts.map +1 -1
- package/lib/types/client/markdown/render.js +22 -6
- package/lib/types/client/markdown/render.js.map +1 -1
- package/lib/types/client/motion.d.ts.map +1 -1
- package/lib/types/client/motion.js +22 -13
- package/lib/types/client/motion.js.map +1 -1
- package/lib/types/client/native/ContextInjectionRow.d.ts +12 -3
- package/lib/types/client/native/ContextInjectionRow.d.ts.map +1 -1
- package/lib/types/client/native/ContextInjectionRow.js +6 -3
- package/lib/types/client/native/ContextInjectionRow.js.map +1 -1
- package/lib/types/client/official-slots.d.ts +71 -0
- package/lib/types/client/official-slots.d.ts.map +1 -0
- package/lib/types/client/official-slots.js +183 -0
- package/lib/types/client/official-slots.js.map +1 -0
- package/lib/types/client/platform-media.d.ts +6 -0
- package/lib/types/client/platform-media.d.ts.map +1 -0
- package/lib/types/client/platform-media.js +13 -0
- package/lib/types/client/platform-media.js.map +1 -0
- package/lib/types/client/settings.js.map +1 -1
- package/lib/types/client/store.d.ts +1 -0
- package/lib/types/client/store.d.ts.map +1 -1
- package/lib/types/client/store.js +1 -0
- package/lib/types/client/store.js.map +1 -1
- package/lib/types/client/tool-activity.d.ts +1 -1
- package/lib/types/client/tool-activity.d.ts.map +1 -1
- package/lib/types/client/tool-activity.js +9 -2
- package/lib/types/client/tool-activity.js.map +1 -1
- package/lib/types/client/types.d.ts +18 -8
- package/lib/types/client/types.d.ts.map +1 -1
- package/lib/types/client/word-motion.d.ts.map +1 -1
- package/lib/types/client/word-motion.js +2 -1
- package/lib/types/client/word-motion.js.map +1 -1
- package/package.json +22 -16
- package/scripts/check-harness-compat.mjs +71 -0
- package/scripts/link-harness-dependencies.mjs +1 -0
- package/scripts/verify-auto-fold.mjs +114 -0
- package/scripts/verify-official-bridge.mjs +119 -0
- package/src/client/Blocks.tsx +2 -1
- package/src/client/OfficialContent.tsx +57 -0
- package/src/client/Reader.module.css +23 -8
- package/src/client/Reader.tsx +37 -9
- package/src/client/ToolActivity.tsx +22 -27
- package/src/client/index.tsx +27 -31
- package/src/client/markdown/MarkdownText.tsx +14 -6
- package/src/client/markdown/render.tsx +23 -7
- package/src/client/motion.tsx +18 -11
- package/src/client/native/ContextInjectionRow.tsx +19 -7
- package/src/client/official-slots.tsx +211 -0
- package/src/client/platform-media.ts +11 -0
- package/src/client/settings.tsx +1 -1
- package/src/client/store.ts +2 -0
- package/src/client/tool-activity.ts +9 -2
- package/src/client/types.ts +14 -8
- package/src/client/word-motion.tsx +2 -1
- package/tests/browser-auto-fold.tsx +64 -0
- package/tests/browser-official-bridge.tsx +144 -0
- package/tests/official-slots.test.ts +112 -0
- package/tests/platform-media.test.ts +17 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React, { useSyncExternalStore } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client';
|
|
4
|
+
import { Reader } from '../src/client/Reader.js';
|
|
5
|
+
import { createReaderStore } from '../src/client/store.js';
|
|
6
|
+
import type { ReaderProps } from '../src/client/types.js';
|
|
7
|
+
|
|
8
|
+
// Real Reader and real root/session stores; only conversation data is synthetic.
|
|
9
|
+
const store = createReaderStore();
|
|
10
|
+
const prefs = store.create();
|
|
11
|
+
const session = store.create('auto-fold-fixture');
|
|
12
|
+
const nodes = new Map();
|
|
13
|
+
const turns = new Map();
|
|
14
|
+
for (const [number, closed] of [[1, true], [2, false]] as const) {
|
|
15
|
+
const turn = {
|
|
16
|
+
turn: number, status: closed ? 'closed' : 'open',
|
|
17
|
+
start: { seq: number * 10, time: 1 },
|
|
18
|
+
end: closed ? { time: 1001, data: { reason: { kind: 'completed' } } } : undefined,
|
|
19
|
+
data: new Map(closed ? [['turn-tail', { closing: { step: 1 } }]] : []),
|
|
20
|
+
steps: [] as any[],
|
|
21
|
+
};
|
|
22
|
+
for (let step = 0; step < 2; step++) {
|
|
23
|
+
const key = `turn-${number}-step-${step}`;
|
|
24
|
+
const data = { turn: number, step, status: closed || step === 0 ? 'settled' : 'running', time: 1,
|
|
25
|
+
blocks: [
|
|
26
|
+
{ kind: 'reasoning', text: closed ? `已完成思考 ${step}` : `进行中思考 ${step}` },
|
|
27
|
+
{ kind: 'text', text: closed && step === 1 ? '最终回答始终保留' : `过程说明 ${number}-${step}` },
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
const location = { step, start: { seq: number * 10 + step }, data: new Map([['assistant-step', data]]) };
|
|
31
|
+
turn.steps.push(location);
|
|
32
|
+
nodes.set(key, { key, kind: 'assistant-step', visibility: 'visible', anchorSeq: number * 10 + step,
|
|
33
|
+
location: { kind: 'step', turn, step: location }, data });
|
|
34
|
+
}
|
|
35
|
+
turns.set(number, turn);
|
|
36
|
+
}
|
|
37
|
+
const chat = { order: [...nodes.keys()], nodes, timeline: { turns } } as unknown as ChatSnapshot;
|
|
38
|
+
const sessionSnapshot = { running: true, openState: 'ready', pendingSubmissions: [], hasMore: false, loadingOlder: false };
|
|
39
|
+
const sessionsSnapshot = { byId: { 'auto-fold-fixture': { cwd: '/fixture' } } };
|
|
40
|
+
const pending = new Map();
|
|
41
|
+
const host = { home: '/fixture' };
|
|
42
|
+
const useReaderStore: ReaderProps['useStore'] = selector => selector(useSyncExternalStore(session.subscribe, session.getSnapshot));
|
|
43
|
+
const props = {
|
|
44
|
+
sessionId: 'auto-fold-fixture', useChat: selector => selector(chat),
|
|
45
|
+
useSession: selector => selector(sessionSnapshot), useSessions: selector => selector(sessionsSnapshot),
|
|
46
|
+
useSessionPendingInteraction: selector => selector(pending),
|
|
47
|
+
useStore: useReaderStore, actions: session.actions, openPrefs: prefs,
|
|
48
|
+
t: key => key, renderSlot: (_name, _owner, options) => options?.fallback ?? null,
|
|
49
|
+
renderSlotChain: (_name, _owner, options) => options?.fallback ?? null,
|
|
50
|
+
loadImage: async () => ({ data: new Uint8Array(), mediaType: 'image/png' }),
|
|
51
|
+
officialImageLoader: Object.assign(async () => null, { peek: () => null }),
|
|
52
|
+
officialFileMentions: () => undefined, officialPreviewFile: () => {},
|
|
53
|
+
officialHost: { getSnapshot: () => host, subscribe: () => () => {} },
|
|
54
|
+
fillComposer: () => true, openFile: () => {}, loadOlder: async () => {}, openView: () => {},
|
|
55
|
+
} as ReaderProps;
|
|
56
|
+
|
|
57
|
+
Object.assign(window, { autoFoldFixture: {
|
|
58
|
+
// Exercise Settings' root preference path without recreating the Reader.
|
|
59
|
+
preference: (value: boolean) => prefs.actions.setAutoFold(value),
|
|
60
|
+
state: () => session.getSnapshot(),
|
|
61
|
+
} });
|
|
62
|
+
createRoot(document.getElementById('app')!).render(
|
|
63
|
+
<div data-conversation-scroll style={{ height: '100vh', overflow: 'auto' }}><Reader {...props} /></div>,
|
|
64
|
+
);
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** Test-only imports resolve to the selected Harness; none enter client.js. */
|
|
2
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
import { memo, useState } from 'react';
|
|
5
|
+
import { SlotRegistry } from '@fixture/registry';
|
|
6
|
+
import { createSlotRenderer } from '@fixture/renderer';
|
|
7
|
+
import { apply as applyFeedback } from '@fixture/feedback';
|
|
8
|
+
import { ReadRow } from '@fixture/read-row';
|
|
9
|
+
import { readImageToolview } from '@fixture/read-image-row';
|
|
10
|
+
import { en as conversationEn } from '@fixture/tool-locale';
|
|
11
|
+
import { officialChildren, installOfficialSlots, OFFICIAL_SEATS } from '../src/client/official-slots.js';
|
|
12
|
+
import { OfficialActions, OfficialTool } from '../src/client/OfficialContent.js';
|
|
13
|
+
import { Deliverables } from '@fixture/deliverables';
|
|
14
|
+
import { zh as deliverablesZh, en as deliverablesEn } from '@fixture/deliverables-locale';
|
|
15
|
+
import { CopyAnswer } from '../src/client/Blocks.js';
|
|
16
|
+
import { MotionMarkdown } from '../src/client/word-motion.js';
|
|
17
|
+
import css from '../src/client/Reader.module.css';
|
|
18
|
+
|
|
19
|
+
const rootContext = new Context();
|
|
20
|
+
await rootContext.plugin(SlotRegistry);
|
|
21
|
+
await rootContext.plugin({ name: 'bridge-fixture', inject: ['slots'], async apply(ctx: Context) {
|
|
22
|
+
const slots = ctx.slots as any;
|
|
23
|
+
slots.install(createSlotRenderer());
|
|
24
|
+
const observations: any = { opens: [], requests: [], mounts: 0, subscriptions: 0 };
|
|
25
|
+
const source = <T,>(initial: T) => {
|
|
26
|
+
let value = initial;
|
|
27
|
+
const listeners = new Set<() => void>();
|
|
28
|
+
return { getSnapshot: () => value, subscribe: (fn: () => void) => {
|
|
29
|
+
listeners.add(fn); observations.subscriptions++;
|
|
30
|
+
return () => { if (listeners.delete(fn)) observations.subscriptions--; };
|
|
31
|
+
}, set(next: T) { value = next; for (const fn of [...listeners]) fn(); } };
|
|
32
|
+
};
|
|
33
|
+
const localeSource = source({ revision: 0 });
|
|
34
|
+
let language = 'zh';
|
|
35
|
+
const dictionaries: Record<string, any> = { deliverables: { zh: deliverablesZh, en: deliverablesEn }, conversation: { zh: conversationEn, en: conversationEn } };
|
|
36
|
+
const locale = {
|
|
37
|
+
...localeSource,
|
|
38
|
+
register(ns: string, dictionary: any) { dictionaries[ns] = dictionary; return () => { delete dictionaries[ns]; }; },
|
|
39
|
+
bind(ns: string) { return (key: string, values?: Record<string, any>) => {
|
|
40
|
+
let text = dictionaries[ns]?.[language]?.[key] ?? dictionaries[ns]?.en?.[key] ?? key;
|
|
41
|
+
for (const [name, value] of Object.entries(values ?? {})) text = text.replaceAll(`{${name}}`, String(value));
|
|
42
|
+
return text;
|
|
43
|
+
}; },
|
|
44
|
+
};
|
|
45
|
+
ctx.provide('locale', locale);
|
|
46
|
+
slots.installLocale(locale);
|
|
47
|
+
const messageItems = new Map();
|
|
48
|
+
ctx.provide('remote', {
|
|
49
|
+
messageFeedback: {
|
|
50
|
+
async list(request: any) { observations.requests.push(['list', request]); return { ok: true, value: { ok: true, value: { items: [...messageItems.values()] } } }; },
|
|
51
|
+
async put(request: any) {
|
|
52
|
+
observations.requests.push(['put', request]);
|
|
53
|
+
const item = { messageId: request.messageId, rating: request.rating, version: (messageItems.get(request.messageId)?.version ?? 0) + 1, ...request.entry };
|
|
54
|
+
messageItems.set(request.messageId, item);
|
|
55
|
+
return { ok: true, value: { ok: true, value: item } };
|
|
56
|
+
},
|
|
57
|
+
async delete(request: any) { observations.requests.push(['delete', request]); messageItems.delete(request.messageId); return { ok: true, value: { ok: true, value: { item: null } } }; },
|
|
58
|
+
},
|
|
59
|
+
sessionFeedback: { record: async () => { throw new Error('Unexpected session feedback'); } },
|
|
60
|
+
});
|
|
61
|
+
const binding = { key: 'session-a', ctx: new Context(), hooks: {}, keyedHooks: {}, props: { sessionId: 'session-a' } };
|
|
62
|
+
const current = source(binding);
|
|
63
|
+
slots.installScope('session', { current, resolve: (id: string) => id === binding.key ? binding : undefined, renderArea: (_b: any, props: any) => props.children });
|
|
64
|
+
const toolBlock = {
|
|
65
|
+
kind: 'tool-result', callId: 'read-1', call: { name: 'read', args: { file_path: '/fixture/sample.ts', offset: 12 }, argsRaw: '{"file_path":"/fixture/sample.ts","offset":12}' },
|
|
66
|
+
content: [{ type: 'text', text: '<path>/fixture/sample.ts</path>\n<type>file</type>\n<content>\n12: export const value = 1;\n</content>' }], isError: false, subCalls: [], time: 100, callTime: 10,
|
|
67
|
+
meta: { path: '/fixture/sample.ts', offset: 12, totalLines: 20, lines: [{ number: 12, text: 'export const value = 1;' }], lang: 'typescript' },
|
|
68
|
+
};
|
|
69
|
+
const host = source({ home: '/fixture' });
|
|
70
|
+
const loadImage = Object.assign(async () => '/pixel.png', { peek: () => '/pixel.png' });
|
|
71
|
+
const blockProps = { loadImage: async () => ({ data: new Uint8Array(), mediaType: 'image/png' }), fillComposer: () => true, cwd: '/fixture', openFile: (path: string, options?: any) => observations.opens.push({ path, options }) };
|
|
72
|
+
let stopReader = () => {};
|
|
73
|
+
let stopMirror = () => {};
|
|
74
|
+
slots.register({ name: 'root', children: {
|
|
75
|
+
'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' },
|
|
76
|
+
'tool.call.toolview': { kind: 'keyed', scope: 'session' },
|
|
77
|
+
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
|
|
78
|
+
'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: { hooks: { turnData: (_s: any, value: any) => () => value } } },
|
|
79
|
+
'conversation.message.images': { kind: 'single', scope: 'session' },
|
|
80
|
+
'conversation.input.overlay': { kind: 'list', scope: 'session' },
|
|
81
|
+
'fixture.reader': { kind: 'single', scope: 'session' },
|
|
82
|
+
} }, ({ SessionProvider, renderSlot }: any) => <SessionProvider>
|
|
83
|
+
{renderSlot('fixture.reader', {}, { fallback: <div data-unmounted>Reader unloaded</div> })}
|
|
84
|
+
{renderSlot('conversation.input.overlay', {})}
|
|
85
|
+
</SessionProvider>);
|
|
86
|
+
await ctx.plugin({ name: 'fixture-official-feedback', inject: ['slots', 'remote', 'locale'], apply: applyFeedback });
|
|
87
|
+
slots.register({ name: 'tool.call.toolview', key: 'read', locale: 'conversation' }, ReadRow);
|
|
88
|
+
readImageToolview.apply(ctx);
|
|
89
|
+
slots.provideRoot({ hooks: { sessions: source({ byId: { 'session-a': { cwd: '/fixture' } } }) } });
|
|
90
|
+
const artifact = { path: '/fixture/report.pdf', description: '演示报告', seq: 42, index: 0 };
|
|
91
|
+
const presentedOpen = source({});
|
|
92
|
+
const presentedHost = source({ available: true, fileManager: 'finder' });
|
|
93
|
+
slots.register({ name: 'conversation.chat.turnTail', locale: 'deliverables',
|
|
94
|
+
select: () => ({ produced: ['/fixture/sample.ts'], presented: [artifact] }),
|
|
95
|
+
inject: () => ({ hooks: { presentedOpen, presentedHost }, reloadPresentedHost: async () => {},
|
|
96
|
+
openPresented: async (...args: any[]) => { observations.opens.push(['presented', ...args]); },
|
|
97
|
+
}),
|
|
98
|
+
}, Deliverables);
|
|
99
|
+
let liveDetail = source('initial');
|
|
100
|
+
const storeHandle = { create: () => {
|
|
101
|
+
observations.mounts++;
|
|
102
|
+
const value = source(0);
|
|
103
|
+
return { ...value, actions: { increment: () => value.set(value.getSnapshot() + 1) }, clearPersisted() {} };
|
|
104
|
+
} };
|
|
105
|
+
slots.register({ name: 'conversation.chat.node', key: 'future-widget', store: storeHandle,
|
|
106
|
+
children: { 'future.detail': { kind: 'single', scope: 'session' } },
|
|
107
|
+
inject: (sessionId: string, actions: any) => ({ hooks: { detail: liveDetail }, injectedSession: sessionId, increment: actions.increment }),
|
|
108
|
+
}, memo(function FutureWidget({ useStore, useDetail, useTurnData, injectedSession, increment, renderSlot }: any) {
|
|
109
|
+
const value = useStore((n: number) => n);
|
|
110
|
+
const detail = useDetail((s: string) => s);
|
|
111
|
+
const context = useTurnData();
|
|
112
|
+
return <div data-memo-widget><button onClick={increment}>Count {value}</button><span>{injectedSession} / {detail} / {context}</span>{renderSlot('future.detail', {})}</div>;
|
|
113
|
+
}));
|
|
114
|
+
slots.register({ name: 'future.detail' }, memo(() => <span data-nested>nested official child</span>));
|
|
115
|
+
|
|
116
|
+
function ReaderFixture({ renderSlot, renderSlotChain }: any) {
|
|
117
|
+
const official = { renderSlot, renderSlotChain, officialHost: host, officialImageLoader: loadImage, officialFileMentions: () => undefined, officialPreviewFile: blockProps.openFile, openView: (...args: any[]) => observations.opens.push(args) };
|
|
118
|
+
const [text, setText] = useState('本地图片:\n\n\n\n普通正文 **加粗** 与 `sample.ts`。');
|
|
119
|
+
return <main className={css.root} data-dsh-better-display="official-fixture"><div className={css.column} data-chat-flow="">
|
|
120
|
+
<article className={css.answer} data-reader-answer>
|
|
121
|
+
<MotionMarkdown text={text} streaming={false} enabled={false} revision={0} />
|
|
122
|
+
<CopyAnswer blocks={[{ kind: 'text', text }]} extraActions={<OfficialActions official={official} messageId={'message-1' as any} />} />
|
|
123
|
+
</article>
|
|
124
|
+
<div data-reader-tool><OfficialTool {...blockProps} official={official} renderSlotChain={renderSlotChain} block={toolBlock as any} toolName="read" fallback={<span>fallback</span>} /></div>
|
|
125
|
+
{renderSlotChain(OFFICIAL_SEATS.tail, { openFile: blockProps.openFile, readerProducedPaths: ['/fixture/sample.ts'] })}
|
|
126
|
+
{renderSlot(OFFICIAL_SEATS.nodes, {}, { entryKey: 'future-widget', hookContext: 'turn-context' })}
|
|
127
|
+
<button onClick={() => setText(text + '\n\n继续输出。')}>Append text</button>
|
|
128
|
+
</div></main>;
|
|
129
|
+
}
|
|
130
|
+
function mountReader() {
|
|
131
|
+
stopReader = slots.register({ name: 'fixture.reader', children: officialChildren(slots) }, ReaderFixture);
|
|
132
|
+
stopMirror = installOfficialSlots(ctx);
|
|
133
|
+
}
|
|
134
|
+
mountReader();
|
|
135
|
+
const root = createRoot(document.getElementById('app')!);
|
|
136
|
+
root.render(slots.renderSlot('root', {}));
|
|
137
|
+
(window as any).fixture = {
|
|
138
|
+
observations, update: () => liveDetail.set('updated'),
|
|
139
|
+
language: () => { language = 'en'; localeSource.set({ revision: 1 }); },
|
|
140
|
+
addAction: () => slots.register({ name: 'conversation.chat.assistant-actions', id: 'future-action', order: 20 }, memo(() => <button>Future official action</button>)),
|
|
141
|
+
unmount: () => { stopMirror(); stopReader(); }, mount: mountReader,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
} });
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { memo } from 'react';
|
|
4
|
+
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots';
|
|
5
|
+
import { mirrorOfficialSlot, readerTailMatch, type CompositionRegistry } from '../src/client/official-slots.js';
|
|
6
|
+
|
|
7
|
+
function registry(kind: 'keyed' | 'chain' | 'list' = 'keyed') {
|
|
8
|
+
const core = new SlotCore();
|
|
9
|
+
const register = (options: object, component: unknown) => (core.register as Function)(options, component) as () => void;
|
|
10
|
+
register({ name: 'root', children: {
|
|
11
|
+
source: { kind, scope: 'session' },
|
|
12
|
+
reader: { kind, scope: 'session' },
|
|
13
|
+
} }, () => null);
|
|
14
|
+
const slots: CompositionRegistry = {
|
|
15
|
+
register,
|
|
16
|
+
spec: key => core.spec(key as never),
|
|
17
|
+
entriesOfSlot: key => core.entriesOfSlot(key),
|
|
18
|
+
subscribe: (key, fn) => core.subscribe(key, fn),
|
|
19
|
+
inject: () => { throw new Error('not used by the subtree test'); },
|
|
20
|
+
};
|
|
21
|
+
return { core, slots, register };
|
|
22
|
+
}
|
|
23
|
+
const changed = () => new Promise(resolve => queueMicrotask(resolve));
|
|
24
|
+
|
|
25
|
+
test('official present cards retain their data while the existing Reader produced row stays unique', () => {
|
|
26
|
+
const original = Object.freeze({ produced: Object.freeze(['/work/code.ts']), presented: Object.freeze([{ path: '/work/report.pdf', seq: 42 }]), future: 'retained' });
|
|
27
|
+
assert.deepEqual(readerTailMatch(original, ['/work/code.ts']), { produced: [], presented: original.presented, future: 'retained' });
|
|
28
|
+
assert.equal(readerTailMatch(original), original);
|
|
29
|
+
assert.deepEqual(readerTailMatch({ produced: ['/work/code.ts', '/work/new.ts'], presented: [] }, ['/work/code.ts']), { produced: ['/work/new.ts'], presented: [] });
|
|
30
|
+
assert.deepEqual(original.produced, ['/work/code.ts']);
|
|
31
|
+
const future = { artifacts: ['/work/report.pdf'] };
|
|
32
|
+
assert.equal(readerTailMatch(future), future);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('official memo components retain injection, store, locale and selector metadata', () => {
|
|
36
|
+
const { core, slots, register } = registry('chain');
|
|
37
|
+
const component = memo(() => null);
|
|
38
|
+
const inject = () => ({ hooks: { status: { getSnapshot: () => 1, subscribe: () => () => {} } } });
|
|
39
|
+
const select = (owner: unknown) => owner;
|
|
40
|
+
const store = { create: () => ({}) };
|
|
41
|
+
register({ name: 'source', select, inject, store, locale: 'feedback', priority: -7 }, component);
|
|
42
|
+
const before = core.entriesOfSlot('source')[0];
|
|
43
|
+
const stop = mirrorOfficialSlot(slots, 'source', 'reader', 'reader.children');
|
|
44
|
+
const mirrored = core.entriesOfSlot('reader')[0];
|
|
45
|
+
assert.equal(mirrored.component, component);
|
|
46
|
+
assert.equal(mirrored.inject, inject);
|
|
47
|
+
assert.equal(mirrored.select, select);
|
|
48
|
+
assert.equal(mirrored.store, store);
|
|
49
|
+
assert.equal(mirrored.locale, 'feedback');
|
|
50
|
+
assert.equal(mirrored.options.priority, -7);
|
|
51
|
+
assert.equal(core.entriesOfSlot('source')[0], before);
|
|
52
|
+
stop();
|
|
53
|
+
assert.equal(core.entriesOfSlot('reader').length, 0);
|
|
54
|
+
assert.equal(core.entriesOfSlot('source')[0], before);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('nested declarations use distinct seats and are disposed without touching the source', () => {
|
|
58
|
+
const { core, slots, register } = registry();
|
|
59
|
+
const hookContext = { hooks: { turnData: () => () => 'live' } };
|
|
60
|
+
register({ name: 'source', key: 'read-image', children: { images: { kind: 'single', scope: 'session', inject: hookContext } } }, () => null);
|
|
61
|
+
const child = memo(() => null);
|
|
62
|
+
register({ name: 'images', locale: 'images' }, child);
|
|
63
|
+
const before = core.snapshot('source');
|
|
64
|
+
const stop = mirrorOfficialSlot(slots, 'source', 'reader', 'reader.children');
|
|
65
|
+
assert.equal(core.entriesOfSlot('reader.children/images')[0].component, child);
|
|
66
|
+
assert.equal(slots.spec('reader.children/images')?.inject, hookContext);
|
|
67
|
+
assert.deepEqual(core.snapshot('source'), before);
|
|
68
|
+
stop();
|
|
69
|
+
assert.equal(slots.spec('reader.children/images'), undefined);
|
|
70
|
+
assert.equal(core.entriesOfSlot('images')[0].component, child);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('new registrations do not remount existing cards; winner changes and unload reconcile', async () => {
|
|
74
|
+
const { core, slots, register } = registry();
|
|
75
|
+
const first = memo(() => null);
|
|
76
|
+
const second = memo(() => null);
|
|
77
|
+
const fallback = memo(() => null);
|
|
78
|
+
register({ name: 'source', key: 'read' }, first);
|
|
79
|
+
const stop = mirrorOfficialSlot(slots, 'source', 'reader', 'reader.children');
|
|
80
|
+
const originalSeat = core.entriesOfSlot('reader')[0];
|
|
81
|
+
const removeNew = register({ name: 'source', key: 'new-official-tool' }, second);
|
|
82
|
+
await changed();
|
|
83
|
+
assert.equal(core.entriesOfSlot('reader').find(e => e.options.key === 'read'), originalSeat);
|
|
84
|
+
assert.equal(core.entriesOfSlot('reader').length, 2);
|
|
85
|
+
const restore = register({ name: 'source', key: 'read', priority: -1 }, fallback);
|
|
86
|
+
await changed();
|
|
87
|
+
assert.equal(core.entriesOfSlot('reader').find(e => e.options.key === 'read')?.component, fallback);
|
|
88
|
+
restore();
|
|
89
|
+
removeNew();
|
|
90
|
+
await changed();
|
|
91
|
+
assert.equal(core.entriesOfSlot('reader').length, 1);
|
|
92
|
+
assert.equal(core.entriesOfSlot('reader')[0].component, first);
|
|
93
|
+
stop();
|
|
94
|
+
register({ name: 'source', key: 'after-reader-unload' }, second);
|
|
95
|
+
await changed();
|
|
96
|
+
assert.equal(core.entriesOfSlot('reader').length, 0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('partial setup rolls back its own registrations on a nested contract failure', () => {
|
|
100
|
+
const { core, slots, register } = registry();
|
|
101
|
+
register({ name: 'source', key: 'nested', children: { detail: { kind: 'single', scope: 'session' } } }, () => null);
|
|
102
|
+
register({ name: 'detail' }, () => null);
|
|
103
|
+
const failing = { ...slots, register(options: Parameters<CompositionRegistry['register']>[0], component: unknown) {
|
|
104
|
+
if (options.name === 'reader.children/detail') throw new Error('fixture failure');
|
|
105
|
+
return slots.register(options, component);
|
|
106
|
+
} };
|
|
107
|
+
assert.throws(() => mirrorOfficialSlot(failing, 'source', 'reader', 'reader.children'), /fixture failure/);
|
|
108
|
+
assert.equal(core.entriesOfSlot('reader').length, 0);
|
|
109
|
+
assert.equal(slots.spec('reader.children/detail'), undefined);
|
|
110
|
+
assert.equal(core.entriesOfSlot('source').length, 1);
|
|
111
|
+
assert.equal(core.entriesOfSlot('detail').length, 1);
|
|
112
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { localPathMediaUrl } from '../src/client/platform-media.js';
|
|
4
|
+
|
|
5
|
+
test('absolute local image paths use the authenticated Host file API without losing authored bytes', () => {
|
|
6
|
+
for (const path of ['/tmp/a.png', '/tmp/中文 图 #1.png', '/tmp/a%20b.png', '/tmp/a?x=1&y=2.png']) {
|
|
7
|
+
const url = localPathMediaUrl('http:', 'http://127.0.0.1:43127', path)!;
|
|
8
|
+
assert.equal(new URL(url).pathname, '/api/file');
|
|
9
|
+
assert.equal(new URL(url).searchParams.get('path'), path);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
test('relative, protocol-relative and non-HTTP page paths are not sent to the Host file API', () => {
|
|
13
|
+
for (const path of ['', 'relative.png', '//host/image.png', 'https://host/image.png', 'data:image/png,x', 'javascript:alert(1)']) {
|
|
14
|
+
assert.equal(localPathMediaUrl('http:', 'http://local', path), undefined);
|
|
15
|
+
}
|
|
16
|
+
assert.equal(localPathMediaUrl('file:', 'null', '/tmp/image.png'), undefined);
|
|
17
|
+
});
|