dsh-better-display 0.2.1 → 0.3.2
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 +24 -0
- package/README.en.md +5 -3
- package/README.md +5 -3
- package/compat/harness-rc2.json +97 -0
- package/docs/official-rendering-bridge.md +65 -0
- package/lib/client.js +6476 -5977
- package/lib/client.js.map +1 -1
- package/lib/dsh-better-display.js +4 -2
- 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 +53 -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 +42 -11
- 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 +23 -19
- 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 +7 -4
- package/lib/types/client/native/ContextInjectionRow.js.map +1 -1
- package/lib/types/client/native/ReferenceIcon.js +3 -3
- package/lib/types/client/native/ReferenceIcon.js.map +1 -1
- package/lib/types/client/native/tool-call-model.js +1 -1
- package/lib/types/client/native/tool-call-model.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/primitive-labels.d.ts.map +1 -1
- package/lib/types/client/primitive-labels.js +3 -2
- package/lib/types/client/primitive-labels.js.map +1 -1
- 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 +11 -4
- 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 +23 -17
- 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 +121 -0
- package/src/client/Blocks.tsx +2 -1
- package/src/client/OfficialContent.tsx +69 -0
- package/src/client/Reader.module.css +35 -8
- package/src/client/Reader.tsx +44 -17
- package/src/client/ToolActivity.tsx +24 -29
- 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 +21 -9
- package/src/client/native/ReferenceIcon.tsx +3 -3
- package/src/client/native/tool-call-model.ts +1 -1
- package/src/client/official-slots.tsx +211 -0
- package/src/client/platform-media.ts +11 -0
- package/src/client/primitive-labels.ts +3 -2
- package/src/client/settings.tsx +1 -1
- package/src/client/store.ts +2 -0
- package/src/client/tool-activity.ts +11 -4
- 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 +152 -0
- package/tests/official-slots.test.ts +112 -0
- package/tests/platform-media.test.ts +17 -0
|
@@ -0,0 +1,152 @@
|
|
|
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 { DeliverablesTail } from '@fixture/deliverables';
|
|
14
|
+
import { zh as deliverablesZh, en as deliverablesEn } from '@fixture/deliverables-locale';
|
|
15
|
+
import { FileRouteAction } from '@fixture/open-in-app-action';
|
|
16
|
+
import { zh as openInAppZh, en as openInAppEn } from '@fixture/open-in-app-locale';
|
|
17
|
+
import { CopyAnswer } from '../src/client/Blocks.js';
|
|
18
|
+
import { MotionMarkdown } from '../src/client/word-motion.js';
|
|
19
|
+
import css from '../src/client/Reader.module.css';
|
|
20
|
+
|
|
21
|
+
const rootContext = new Context();
|
|
22
|
+
await rootContext.plugin(SlotRegistry);
|
|
23
|
+
await rootContext.plugin({ name: 'bridge-fixture', inject: ['slots'], async apply(ctx: Context) {
|
|
24
|
+
const slots = ctx.slots as any;
|
|
25
|
+
slots.install(createSlotRenderer());
|
|
26
|
+
const observations: any = { opens: [], requests: [], mounts: 0, subscriptions: 0 };
|
|
27
|
+
const source = <T,>(initial: T) => {
|
|
28
|
+
let value = initial;
|
|
29
|
+
const listeners = new Set<() => void>();
|
|
30
|
+
return { getSnapshot: () => value, subscribe: (fn: () => void) => {
|
|
31
|
+
listeners.add(fn); observations.subscriptions++;
|
|
32
|
+
return () => { if (listeners.delete(fn)) observations.subscriptions--; };
|
|
33
|
+
}, set(next: T) { value = next; for (const fn of [...listeners]) fn(); } };
|
|
34
|
+
};
|
|
35
|
+
const localeSource = source({ revision: 0 });
|
|
36
|
+
let language = 'zh';
|
|
37
|
+
const dictionaries: Record<string, any> = { deliverables: { zh: deliverablesZh, en: deliverablesEn }, 'open-in-app': { zh: openInAppZh, en: openInAppEn }, conversation: { zh: conversationEn, en: conversationEn } };
|
|
38
|
+
const locale = {
|
|
39
|
+
...localeSource,
|
|
40
|
+
register(ns: string, dictionary: any) { dictionaries[ns] = dictionary; return () => { delete dictionaries[ns]; }; },
|
|
41
|
+
bind(ns: string) { return (key: string, values?: Record<string, any>) => {
|
|
42
|
+
let text = dictionaries[ns]?.[language]?.[key] ?? dictionaries[ns]?.en?.[key] ?? key;
|
|
43
|
+
for (const [name, value] of Object.entries(values ?? {})) text = text.replaceAll(`{${name}}`, String(value));
|
|
44
|
+
return text;
|
|
45
|
+
}; },
|
|
46
|
+
};
|
|
47
|
+
ctx.provide('locale', locale);
|
|
48
|
+
slots.installLocale(locale);
|
|
49
|
+
const messageItems = new Map();
|
|
50
|
+
ctx.provide('remote', {
|
|
51
|
+
messageFeedback: {
|
|
52
|
+
async list(request: any) { observations.requests.push(['list', request]); return { ok: true, value: { ok: true, value: { items: [...messageItems.values()] } } }; },
|
|
53
|
+
async put(request: any) {
|
|
54
|
+
observations.requests.push(['put', request]);
|
|
55
|
+
const item = { messageId: request.messageId, rating: request.rating, version: (messageItems.get(request.messageId)?.version ?? 0) + 1, ...request.entry };
|
|
56
|
+
messageItems.set(request.messageId, item);
|
|
57
|
+
return { ok: true, value: { ok: true, value: item } };
|
|
58
|
+
},
|
|
59
|
+
async delete(request: any) { observations.requests.push(['delete', request]); messageItems.delete(request.messageId); return { ok: true, value: { ok: true, value: { item: null } } }; },
|
|
60
|
+
},
|
|
61
|
+
sessionFeedback: { record: async () => { throw new Error('Unexpected session feedback'); } },
|
|
62
|
+
});
|
|
63
|
+
const binding = { key: 'session-a', ctx: new Context(), hooks: {}, keyedHooks: {}, props: { sessionId: 'session-a' } };
|
|
64
|
+
const current = source(binding);
|
|
65
|
+
slots.installScope('session', { current, bindingSource: (target: any) => source(target === binding.key ? binding : undefined), renderArea: (_b: any, props: any) => props.children });
|
|
66
|
+
const toolBlock = {
|
|
67
|
+
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}' },
|
|
68
|
+
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,
|
|
69
|
+
meta: { path: '/fixture/sample.ts', offset: 12, totalLines: 20, lines: [{ number: 12, text: 'export const value = 1;' }], lang: 'typescript' },
|
|
70
|
+
};
|
|
71
|
+
const host = source({ home: '/fixture' });
|
|
72
|
+
const loadImage = Object.assign(async () => '/pixel.png', { peek: () => '/pixel.png' });
|
|
73
|
+
const blockProps = { loadImage: async () => ({ data: new Uint8Array(), mediaType: 'image/png' }), fillComposer: () => true, cwd: '/fixture', openFile: (path: string, options?: any) => observations.opens.push({ path, options }) };
|
|
74
|
+
let stopReader = () => {};
|
|
75
|
+
let stopMirror = () => {};
|
|
76
|
+
slots.register({ name: 'root', children: {
|
|
77
|
+
'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' },
|
|
78
|
+
'tool.call.toolview': { kind: 'keyed', scope: 'session' },
|
|
79
|
+
'conversation.chat.turnTail': { kind: 'list', scope: 'session' },
|
|
80
|
+
'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: { hooks: { turnData: (_s: any, value: any) => () => value } } },
|
|
81
|
+
'conversation.message.images': { kind: 'single', scope: 'session' },
|
|
82
|
+
'conversation.input.overlay': { kind: 'list', scope: 'session' },
|
|
83
|
+
'fixture.reader': { kind: 'single', scope: 'session' },
|
|
84
|
+
} }, ({ SessionProvider, renderSlot }: any) => <SessionProvider>
|
|
85
|
+
{renderSlot('fixture.reader', {}, { fallback: <div data-unmounted>Reader unloaded</div> })}
|
|
86
|
+
{renderSlot('conversation.input.overlay', {})}
|
|
87
|
+
</SessionProvider>);
|
|
88
|
+
await ctx.plugin({ name: 'fixture-official-feedback', inject: ['slots', 'remote', 'locale'], apply: applyFeedback });
|
|
89
|
+
slots.register({ name: 'tool.call.toolview', key: 'read', locale: 'conversation' }, ReadRow);
|
|
90
|
+
readImageToolview.apply(ctx);
|
|
91
|
+
slots.provideRoot({ hooks: { sessions: source({ byId: { 'session-a': { cwd: '/fixture' } } }) } });
|
|
92
|
+
const artifact = { path: '/fixture/report.pdf', description: '演示报告', seq: 42, index: 0 };
|
|
93
|
+
const presentedOpen = source({});
|
|
94
|
+
const presentedHost = source({ available: true, fileManager: 'finder' });
|
|
95
|
+
const changesDiff = source({});
|
|
96
|
+
const changesSummary = source({});
|
|
97
|
+
const showCodeDiff = source(false);
|
|
98
|
+
slots.register({ name: 'conversation.chat.turnTail', id: '@deepseek-ai/dsh-client-ui-deliverables', locale: 'deliverables',
|
|
99
|
+
children: { 'deliverables.file.actions': { kind: 'list', scope: 'session' } },
|
|
100
|
+
inject: () => ({ hooks: { changesDiff, showCodeDiff, presentedOpen, presentedHost, changesSummary },
|
|
101
|
+
loadChangesDiff: async () => {}, reloadPresentedHost: async () => {}, loadChangesSummary: async () => {},
|
|
102
|
+
openPresented: async (...args: any[]) => { observations.opens.push(['presented', ...args]); },
|
|
103
|
+
openChanged: async () => {}, openChangesReview: () => {},
|
|
104
|
+
}),
|
|
105
|
+
}, DeliverablesTail);
|
|
106
|
+
slots.register({ name: 'deliverables.file.actions', id: 'open-in-app', locale: 'open-in-app' }, FileRouteAction);
|
|
107
|
+
let liveDetail = source('initial');
|
|
108
|
+
const storeHandle = { create: () => {
|
|
109
|
+
observations.mounts++;
|
|
110
|
+
const value = source(0);
|
|
111
|
+
return { ...value, actions: { increment: () => value.set(value.getSnapshot() + 1) }, clearPersisted() {} };
|
|
112
|
+
} };
|
|
113
|
+
slots.register({ name: 'conversation.chat.node', key: 'future-widget', store: storeHandle,
|
|
114
|
+
children: { 'future.detail': { kind: 'single', scope: 'session' } },
|
|
115
|
+
inject: (sessionId: string, actions: any) => ({ hooks: { detail: liveDetail }, injectedSession: sessionId, increment: actions.increment }),
|
|
116
|
+
}, memo(function FutureWidget({ useStore, useDetail, useTurnData, injectedSession, increment, renderSlot }: any) {
|
|
117
|
+
const value = useStore((n: number) => n);
|
|
118
|
+
const detail = useDetail((s: string) => s);
|
|
119
|
+
const context = useTurnData();
|
|
120
|
+
return <div data-memo-widget><button onClick={increment}>Count {value}</button><span>{injectedSession} / {detail} / {context}</span>{renderSlot('future.detail', {})}</div>;
|
|
121
|
+
}));
|
|
122
|
+
slots.register({ name: 'future.detail' }, memo(() => <span data-nested>nested official child</span>));
|
|
123
|
+
|
|
124
|
+
function ReaderFixture({ renderSlot, renderSlotChain }: any) {
|
|
125
|
+
const official = { renderSlot, renderSlotChain, officialHost: host, officialImageLoader: loadImage, officialFileMentions: () => undefined, officialPreviewFile: blockProps.openFile, openView: (...args: any[]) => observations.opens.push(args) };
|
|
126
|
+
const [text, setText] = useState('本地图片:\n\n\n\n普通正文 **加粗** 与 `sample.ts`。');
|
|
127
|
+
return <main className={css.root} data-dsh-better-display="official-fixture"><div className={css.column} data-chat-flow="">
|
|
128
|
+
<article className={css.answer} data-reader-answer>
|
|
129
|
+
<MotionMarkdown text={text} streaming={false} enabled={false} revision={0} />
|
|
130
|
+
<CopyAnswer blocks={[{ kind: 'text', text }]} extraActions={<OfficialActions official={official} messageId={'message-1' as any} />} />
|
|
131
|
+
</article>
|
|
132
|
+
<div data-reader-tool><OfficialTool {...blockProps} official={official} renderSlotChain={renderSlotChain} block={toolBlock as any} toolName="read" fallback={<span>fallback</span>} /></div>
|
|
133
|
+
{renderSlot(OFFICIAL_SEATS.tail, { turn: { data: new Map([['deliverables', { changes: null, presented: [artifact] }]]) }, seq: 43, openFile: blockProps.openFile, readerProducedPaths: ['/fixture/sample.ts'] })}
|
|
134
|
+
{renderSlot(OFFICIAL_SEATS.nodes, {}, { entryKey: 'future-widget', hookContext: 'turn-context' })}
|
|
135
|
+
<button onClick={() => setText(text + '\n\n继续输出。')}>Append text</button>
|
|
136
|
+
</div></main>;
|
|
137
|
+
}
|
|
138
|
+
function mountReader() {
|
|
139
|
+
stopReader = slots.register({ name: 'fixture.reader', children: officialChildren(slots) }, ReaderFixture);
|
|
140
|
+
stopMirror = installOfficialSlots(ctx);
|
|
141
|
+
}
|
|
142
|
+
mountReader();
|
|
143
|
+
const root = createRoot(document.getElementById('app')!);
|
|
144
|
+
root.render(slots.renderSlot('root', {}));
|
|
145
|
+
(window as any).fixture = {
|
|
146
|
+
observations, update: () => liveDetail.set('updated'),
|
|
147
|
+
language: () => { language = 'en'; localeSource.set({ revision: 1 }); },
|
|
148
|
+
addAction: () => slots.register({ name: 'conversation.chat.assistant-actions', id: 'future-action', order: 20 }, memo(() => <button>Future official action</button>)),
|
|
149
|
+
unmount: () => { stopMirror(); stopReader(); }, mount: mountReader,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
} });
|
|
@@ -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
|
+
});
|