dsh-nextsteps 1.3.4 → 1.4.1
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/client.js +5 -5
- package/index.js +12 -17
- package/lib/locale/en.json +4 -5
- package/lib/locale/pt-BR.json +4 -5
- package/lib/locale/zh-CN.json +4 -5
- package/package.json +7 -2
- package/scripts/client-harness.mjs +202 -0
- package/scripts/harness.mjs +96 -0
- package/scripts/suite/client.test.mjs +218 -0
- package/scripts/suite/contract.test.mjs +64 -0
- package/scripts/suite/host.test.mjs +291 -0
- package/scripts/suite/package.test.mjs +169 -0
- package/scripts/test.mjs +4 -0
package/client.js
CHANGED
|
@@ -144,7 +144,7 @@ window.__ModuleLoader__.load({
|
|
|
144
144
|
|
|
145
145
|
const captures = new Map();
|
|
146
146
|
const captureListeners = new Set();
|
|
147
|
-
const CAPTURES_KEY = 'dsh
|
|
147
|
+
const CAPTURES_KEY = 'dsh-nextsteps.captures.v1';
|
|
148
148
|
const CAPTURE_LIMIT = 400;
|
|
149
149
|
|
|
150
150
|
function readStoredCaptures() {
|
|
@@ -366,9 +366,9 @@ window.__ModuleLoader__.load({
|
|
|
366
366
|
const SETTINGS_ROUTE = '/api/dsh-nextsteps.settings';
|
|
367
367
|
const UPDATE_ROUTE = '/api/dsh-nextsteps.update';
|
|
368
368
|
const DICTIONARIES = {
|
|
369
|
-
en: {"header":"Suggested followups","showEarlier":"Show earlier suggestions","hideEarlier":"Hide earlier suggestions","srFollowupSent":"Followup sent","sendFailed":"Could not send: ","intro":"Suggested next-step prompts under every finished agent turn — one click sends the followup.","setLanguage":"Language","setLanguageTip":"Followups are generated in this language. Auto follows the conversation.","optAuto":"Auto (follow the conversation)","optEn":"English","optPt":"Português (Brasil)","optZh":"简体中文","setModel":"Model","setModelTip":"The model that generates the suggestions. Auto keeps them inside the main turn.","optModelAuto":"Auto (same model as the chat)","setMax":"Followups per turn","setMaxTip":"How many suggested prompts the model should produce (1-6).","setHeader":"Show header","setHeaderTip":"The small “Suggested followups” label above the cards.","updates":"Updates","current":"Version","checking":"Checking…","upToDate":"up to date","available":"{v} is available","manualCopy":"Development or renamed copy — update it manually.","installing":"Installing {v}…","restartMsg":"Version {v} was installed. Restart the dsh to use it.","noReleases":"No releases published yet","checkFailed":"Update check failed ({e})","updateNow":"Update now","check":"Check for updates","openRelease":"Open release page","releaseNotes":"Release notes","repo":"GitHub repository","issues":"Report an issue"},
|
|
370
|
-
'pt-BR': {"header":"Sugestões de acompanhamento","showEarlier":"Mostrar sugestões anteriores","hideEarlier":"Ocultar sugestões anteriores","srFollowupSent":"Follow-up enviado","sendFailed":"Não consegui enviar: ","intro":"Sugestões de próximos passos sob cada turno concluído do agente — um clique envia o followup.","setLanguage":"Idioma","setLanguageTip":"Os followups são gerados neste idioma. Automático segue a conversa.","optAuto":"Automático (segue a conversa)","optEn":"English","optPt":"Português (Brasil)","optZh":"简体中文","setModel":"Modelo","setModelTip":"O modelo que gera as sugestões. Automático as mantém no turno principal.","optModelAuto":"Automático (mesmo modelo da conversa)","setMax":"Followups por turno","setMaxTip":"Quantas sugestões o modelo deve produzir (1-6).","setHeader":"Mostrar cabeçalho","setHeaderTip":"O rótulo pequeno “Sugestões de acompanhamento” acima dos cartões.","updates":"Atualizações","current":"Versão","checking":"Verificando…","upToDate":"em dia","available":"{v} disponível","manualCopy":"Cópia de desenvolvimento ou renomeada — atualize manualmente.","installing":"Instalando {v}…","restartMsg":"Versão {v} instalada. Reinicie o dsh para usá-la.","noReleases":"Nenhum release publicado ainda","checkFailed":"Falha ao verificar atualizações ({e})","updateNow":"Atualizar agora","check":"Verificar atualizações","openRelease":"Abrir página do release","releaseNotes":"Notas do release","repo":"Repositório no GitHub","issues":"Reportar um problema"},
|
|
371
|
-
'zh-CN': {"header":"建议的后续操作","showEarlier":"显示较早的建议","hideEarlier":"隐藏较早的建议","srFollowupSent":"后续操作已发送","sendFailed":"发送失败:","intro":"在每个智能体回合完成后的下方推荐下一步提示——单击即可发送。","setLanguage":"语言","setLanguageTip":"后续操作提示将以此语言生成。“自动”会跟随对话语言。","optAuto":"自动(跟随对话)","optEn":"English","optPt":"Português (Brasil)","optZh":"简体中文","setModel":"模型","setModelTip":"生成建议的模型。自动会跟随对话所用的模型。","optModelAuto":"自动(与对话相同的模型)","setMax":"每回合提示数","setMaxTip":"模型应生成的建议提示数量(1-6)。","setHeader":"显示标题","setHeaderTip":"卡片上方的小标题“建议的后续操作”。","updates":"更新","current":"版本","checking":"检查中…","upToDate":"已是最新","available":"{v} 可用","manualCopy":"开发或重命名的副本——请手动更新。","installing":"正在安装 {v}…","restartMsg":"版本 {v} 已安装。请重启 dsh 以使用。","noReleases":"尚未发布任何版本","checkFailed":"检查更新失败({e})","updateNow":"立即更新","check":"检查更新","openRelease":"打开发布页","releaseNotes":"发布说明","repo":"GitHub 仓库","issues":"反馈问题"}
|
|
369
|
+
en: {"header":"Suggested followups","showEarlier":"Show earlier suggestions","hideEarlier":"Hide earlier suggestions","srFollowupSent":"Followup sent","sendFailed":"Could not send: ","intro":"Suggested next-step prompts under every finished agent turn — one click sends the followup.","setLanguage":"Language","setLanguageTip":"Followups are generated in this language. Auto follows the conversation.","optAuto":"Auto (follow the conversation)","optEn":"English","optPt":"Português (Brasil)","optZh":"简体中文","setModel":"Model","setModelTip":"The model that generates the suggestions. Auto keeps them inside the main turn.","optModelAuto":"Auto (same model as the chat)","modelUnavailable":"No model list available in this install","setMax":"Followups per turn","setMaxTip":"How many suggested prompts the model should produce (1-6).","setHeader":"Show header","setHeaderTip":"The small “Suggested followups” label above the cards.","updates":"Updates","current":"Version","checking":"Checking…","upToDate":"up to date","available":"{v} is available","manualCopy":"Development or renamed copy — update it manually.","installing":"Installing {v}…","restartMsg":"Version {v} was installed. Restart the dsh to use it.","noReleases":"No releases published yet","checkFailed":"Update check failed ({e})","updateNow":"Update now","check":"Check for updates","openRelease":"Open release page","releaseNotes":"Release notes","repo":"GitHub repository","issues":"Report an issue"},
|
|
370
|
+
'pt-BR': {"header":"Sugestões de acompanhamento","showEarlier":"Mostrar sugestões anteriores","hideEarlier":"Ocultar sugestões anteriores","srFollowupSent":"Follow-up enviado","sendFailed":"Não consegui enviar: ","intro":"Sugestões de próximos passos sob cada turno concluído do agente — um clique envia o followup.","setLanguage":"Idioma","setLanguageTip":"Os followups são gerados neste idioma. Automático segue a conversa.","optAuto":"Automático (segue a conversa)","optEn":"English","optPt":"Português (Brasil)","optZh":"简体中文","setModel":"Modelo","setModelTip":"O modelo que gera as sugestões. Automático as mantém no turno principal.","optModelAuto":"Automático (mesmo modelo da conversa)","modelUnavailable":"Nenhuma lista de modelos disponível nesta instalação","setMax":"Followups por turno","setMaxTip":"Quantas sugestões o modelo deve produzir (1-6).","setHeader":"Mostrar cabeçalho","setHeaderTip":"O rótulo pequeno “Sugestões de acompanhamento” acima dos cartões.","updates":"Atualizações","current":"Versão","checking":"Verificando…","upToDate":"em dia","available":"{v} disponível","manualCopy":"Cópia de desenvolvimento ou renomeada — atualize manualmente.","installing":"Instalando {v}…","restartMsg":"Versão {v} instalada. Reinicie o dsh para usá-la.","noReleases":"Nenhum release publicado ainda","checkFailed":"Falha ao verificar atualizações ({e})","updateNow":"Atualizar agora","check":"Verificar atualizações","openRelease":"Abrir página do release","releaseNotes":"Notas do release","repo":"Repositório no GitHub","issues":"Reportar um problema"},
|
|
371
|
+
'zh-CN': {"header":"建议的后续操作","showEarlier":"显示较早的建议","hideEarlier":"隐藏较早的建议","srFollowupSent":"后续操作已发送","sendFailed":"发送失败:","intro":"在每个智能体回合完成后的下方推荐下一步提示——单击即可发送。","setLanguage":"语言","setLanguageTip":"后续操作提示将以此语言生成。“自动”会跟随对话语言。","optAuto":"自动(跟随对话)","optEn":"English","optPt":"Português (Brasil)","optZh":"简体中文","setModel":"模型","setModelTip":"生成建议的模型。自动会跟随对话所用的模型。","optModelAuto":"自动(与对话相同的模型)","modelUnavailable":"此安装没有可用的模型列表","setMax":"每回合提示数","setMaxTip":"模型应生成的建议提示数量(1-6)。","setHeader":"显示标题","setHeaderTip":"卡片上方的小标题“建议的后续操作”。","updates":"更新","current":"版本","checking":"检查中…","upToDate":"已是最新","available":"{v} 可用","manualCopy":"开发或重命名的副本——请手动更新。","installing":"正在安装 {v}…","restartMsg":"版本 {v} 已安装。请重启 dsh 以使用。","noReleases":"尚未发布任何版本","checkFailed":"检查更新失败({e})","updateNow":"立即更新","check":"检查更新","openRelease":"打开发布页","releaseNotes":"发布说明","repo":"GitHub 仓库","issues":"反馈问题"}
|
|
372
372
|
};
|
|
373
373
|
let pluginSettings = { lang: 'auto', maxFollowups: 3, showHeader: true };
|
|
374
374
|
let pluginVersion;
|
|
@@ -614,7 +614,7 @@ window.__ModuleLoader__.load({
|
|
|
614
614
|
row(t('setLanguage'), t('setLanguageTip'), h('select', { value: settings.lang, onChange: (event) => save({ lang: event.target.value }), style: controlStyle, 'aria-label': t('setLanguage') },
|
|
615
615
|
h('option', { value: 'auto' }, t('optAuto')),
|
|
616
616
|
...LANGUAGE_OPTIONS.map((option) => h('option', { key: option.value, value: option.value }, option.label))), 'lang'),
|
|
617
|
-
row(t('setModel'), t('setModelTip'),
|
|
617
|
+
row(t('setModel'), t('setModelTip'), models === undefined ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } }, t('checking')) : (models.groups ?? []).length === 0 ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } }, t('modelUnavailable')) : h('select', { value: settings.model, onChange: (event) => save({ model: event.target.value }), style: controlStyle, 'aria-label': t('setModel') },
|
|
618
618
|
h('option', { value: '' }, t('optModelAuto')),
|
|
619
619
|
...(models.groups ?? []).map((group) => h('optgroup', { key: group.id, label: group.name },
|
|
620
620
|
group.models.map((entry) => h('option', { key: group.id + '/' + entry.id, value: group.id + '/' + entry.id }, entry.name === entry.id ? entry.name : entry.name + ' (' + entry.id + ')'))))), 'model'),
|
package/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
|
|
7
7
|
const TOOL_NAME = 'suggest_followups';
|
|
8
8
|
const name = 'dsh-nextsteps';
|
|
9
|
-
const inject = ['tools', 'connection'
|
|
9
|
+
const inject = ['tools', 'connection'];
|
|
10
10
|
const NPM_NAME = 'dsh-nextsteps';
|
|
11
11
|
const RELEASES_URL = 'https://api.github.com/repos/klaus-2/dsh-nextsteps/releases/latest';
|
|
12
12
|
const SETTINGS_FILE = path.join(homedir(), '.dsh', 'dsh-nextsteps', 'settings.json');
|
|
@@ -133,10 +133,10 @@ function toolDescription(external = false) {
|
|
|
133
133
|
}
|
|
134
134
|
return [
|
|
135
135
|
'MANDATORY in every turn: once all real work and other tool calls are done, call this tool BEFORE writing your final answer — the answer must come after this tool returns, so the turn ends cleanly right after it. Never write your final answer before calling it.',
|
|
136
|
-
'Suggest exactly the number of followup prompts stated in the nextsteps section of the system prompt.',
|
|
136
|
+
'Suggest exactly the number of followup prompts stated in the nextsteps section of the system prompt; when there is no such section, suggest three.',
|
|
137
137
|
'Good suggestions include alternatives to the latest implementation, related features, cleanup opportunities, tests to add, or verification steps you could run yourself; if plan steps remain, one suggestion may be continuing with the next step.',
|
|
138
138
|
`Each prompt must be short (usually one sentence), goal-oriented — name the outcome, not the steps — stand alone without conversation context, and be phrased in the user's first-person voice.`,
|
|
139
|
-
'Write every prompt and label in the language the nextsteps section of the system prompt states.',
|
|
139
|
+
'Write every prompt and label in the language the nextsteps section of the system prompt states; when there is no such section, use the language of the conversation.',
|
|
140
140
|
'Never suggest actions the user would carry out themselves outside this session (e.g. "commit these changes"); judge by the tools you actually have.',
|
|
141
141
|
'This never waits for an answer: its result only confirms the suggestions were recorded.',
|
|
142
142
|
'Example input:',
|
|
@@ -362,20 +362,15 @@ async function apply(ctx) {
|
|
|
362
362
|
} catch (error) {
|
|
363
363
|
console.error('dsh-nextsteps: tool registration failed:', error);
|
|
364
364
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
sub.systemPrompt.section({ name: 'dsh-nextsteps', order: 640, interpolate: false, text: () => nextstepsSectionText() });
|
|
375
|
-
} catch (error) {
|
|
376
|
-
console.error('dsh-nextsteps: system prompt section rejected:', error);
|
|
377
|
-
}
|
|
378
|
-
});
|
|
365
|
+
const systemPrompt = get(ctx, 'systemPrompt');
|
|
366
|
+
if (systemPrompt !== undefined && typeof systemPrompt.section === 'function') {
|
|
367
|
+
try {
|
|
368
|
+
systemPrompt.section({ name: 'dsh-nextsteps', order: 640, interpolate: false, text: () => nextstepsSectionText() });
|
|
369
|
+
} catch (error) {
|
|
370
|
+
console.error('dsh-nextsteps: system prompt section rejected:', error);
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
console.warn('dsh-nextsteps: the system prompt service is unavailable; the suggestions follow the conversation language');
|
|
379
374
|
}
|
|
380
375
|
ctx.connection?.fetch?.register({
|
|
381
376
|
path: '/api/dsh-nextsteps.settings',
|
package/lib/locale/en.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
+
"setModel": "Model",
|
|
3
|
+
"setModelTip": "The model that generates the suggestions. Auto keeps them inside the main turn.",
|
|
4
|
+
"optModelAuto": "Auto (same model as the chat)",
|
|
5
|
+
"modelUnavailable": "No model list available in this install",
|
|
2
6
|
"header": "Suggested followups",
|
|
3
|
-
"showEarlier": "Show earlier suggestions",
|
|
4
|
-
"hideEarlier": "Hide earlier suggestions",
|
|
5
7
|
"srFollowupSent": "Followup sent",
|
|
6
8
|
"sendFailed": "Could not send: ",
|
|
7
9
|
"meta": {
|
|
@@ -12,9 +14,6 @@
|
|
|
12
14
|
"setLanguage": "Language",
|
|
13
15
|
"setLanguageTip": "Followups are generated in this language. Auto follows the conversation.",
|
|
14
16
|
"optAuto": "Auto (follow the conversation)",
|
|
15
|
-
"optEn": "English",
|
|
16
|
-
"optPt": "Português (Brasil)",
|
|
17
|
-
"optZh": "简体中文",
|
|
18
17
|
"setMax": "Followups per turn",
|
|
19
18
|
"setMaxTip": "How many suggested prompts the model should produce (1-6).",
|
|
20
19
|
"setHeader": "Show header",
|
package/lib/locale/pt-BR.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
+
"setModel": "Modelo",
|
|
3
|
+
"setModelTip": "O modelo que gera as sugestões. Automático as mantém no turno principal.",
|
|
4
|
+
"optModelAuto": "Automático (mesmo modelo da conversa)",
|
|
5
|
+
"modelUnavailable": "Nenhuma lista de modelos disponível nesta instalação",
|
|
2
6
|
"header": "Sugestões de acompanhamento",
|
|
3
|
-
"showEarlier": "Mostrar sugestões anteriores",
|
|
4
|
-
"hideEarlier": "Ocultar sugestões anteriores",
|
|
5
7
|
"srFollowupSent": "Follow-up enviado",
|
|
6
8
|
"sendFailed": "Não consegui enviar: ",
|
|
7
9
|
"meta": {
|
|
@@ -12,9 +14,6 @@
|
|
|
12
14
|
"setLanguage": "Idioma",
|
|
13
15
|
"setLanguageTip": "Os followups são gerados neste idioma. Automático segue a conversa.",
|
|
14
16
|
"optAuto": "Automático (segue a conversa)",
|
|
15
|
-
"optEn": "English",
|
|
16
|
-
"optPt": "Português (Brasil)",
|
|
17
|
-
"optZh": "简体中文",
|
|
18
17
|
"setMax": "Followups por turno",
|
|
19
18
|
"setMaxTip": "Quantas sugestões o modelo deve produzir (1-6).",
|
|
20
19
|
"setHeader": "Mostrar cabeçalho",
|
package/lib/locale/zh-CN.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
+
"setModel": "模型",
|
|
3
|
+
"setModelTip": "生成建议的模型。自动会跟随对话所用的模型。",
|
|
4
|
+
"optModelAuto": "自动(与对话相同的模型)",
|
|
5
|
+
"modelUnavailable": "此安装没有可用的模型列表",
|
|
2
6
|
"header": "建议的后续操作",
|
|
3
|
-
"showEarlier": "显示较早的建议",
|
|
4
|
-
"hideEarlier": "隐藏较早的建议",
|
|
5
7
|
"srFollowupSent": "后续操作已发送",
|
|
6
8
|
"sendFailed": "发送失败:",
|
|
7
9
|
"meta": {
|
|
@@ -12,9 +14,6 @@
|
|
|
12
14
|
"setLanguage": "语言",
|
|
13
15
|
"setLanguageTip": "后续操作提示将以此语言生成。“自动”会跟随对话语言。",
|
|
14
16
|
"optAuto": "自动(跟随对话)",
|
|
15
|
-
"optEn": "English",
|
|
16
|
-
"optPt": "Português (Brasil)",
|
|
17
|
-
"optZh": "简体中文",
|
|
18
17
|
"setMax": "每回合提示数",
|
|
19
18
|
"setMaxTip": "模型应生成的建议提示数量(1-6)。",
|
|
20
19
|
"setHeader": "显示标题",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-nextsteps",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./index.js",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"README.md",
|
|
17
17
|
"LICENSE",
|
|
18
18
|
"README.pt-BR.md",
|
|
19
|
-
"README.zh-CN.md"
|
|
19
|
+
"README.zh-CN.md",
|
|
20
|
+
"scripts"
|
|
20
21
|
],
|
|
21
22
|
"dsh": {
|
|
22
23
|
"bundle": {
|
|
@@ -49,5 +50,9 @@
|
|
|
49
50
|
"bugs": {
|
|
50
51
|
"url": "https://github.com/klaus-2/dsh-nextsteps/issues"
|
|
51
52
|
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"test": "node tools/regression.mjs",
|
|
55
|
+
"regression": "node tools/regression.mjs"
|
|
56
|
+
},
|
|
52
57
|
"icon": "./docs/assets/icon.png"
|
|
53
58
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
6
|
+
|
|
7
|
+
/** A React-shaped stub good enough to render the plugin's function components. */
|
|
8
|
+
export function fakeReact() {
|
|
9
|
+
const state = [];
|
|
10
|
+
let cursor = 0;
|
|
11
|
+
const hooks = {
|
|
12
|
+
useState(initial) {
|
|
13
|
+
const index = cursor++;
|
|
14
|
+
if (state.length <= index) state[index] = typeof initial === "function" ? initial() : initial;
|
|
15
|
+
const setValue = (next) => {
|
|
16
|
+
state[index] = typeof next === "function" ? next(state[index]) : next;
|
|
17
|
+
};
|
|
18
|
+
return [state[index], setValue];
|
|
19
|
+
},
|
|
20
|
+
useReducer(reducer, initial) {
|
|
21
|
+
const index = cursor++;
|
|
22
|
+
if (state.length <= index) state[index] = initial;
|
|
23
|
+
const dispatch = (action) => {
|
|
24
|
+
state[index] = reducer(state[index], action);
|
|
25
|
+
};
|
|
26
|
+
return [state[index], dispatch];
|
|
27
|
+
},
|
|
28
|
+
useRef(initial) {
|
|
29
|
+
const index = cursor++;
|
|
30
|
+
if (state.length <= index) state[index] = { current: initial };
|
|
31
|
+
return state[index];
|
|
32
|
+
},
|
|
33
|
+
useMemo(factory) {
|
|
34
|
+
return factory();
|
|
35
|
+
},
|
|
36
|
+
useCallback(fn) {
|
|
37
|
+
return fn;
|
|
38
|
+
},
|
|
39
|
+
useEffect() {},
|
|
40
|
+
useSyncExternalStore(subscribe, getSnapshot) {
|
|
41
|
+
return getSnapshot();
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const createElement = (type, props, ...children) => {
|
|
45
|
+
const flat = [];
|
|
46
|
+
const push = (child) => {
|
|
47
|
+
if (child === null || child === undefined || child === false) return;
|
|
48
|
+
if (Array.isArray(child)) {
|
|
49
|
+
for (const item of child) push(item);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
flat.push(child);
|
|
53
|
+
};
|
|
54
|
+
for (const child of children) push(child);
|
|
55
|
+
return { type, props: props ?? {}, children: flat };
|
|
56
|
+
};
|
|
57
|
+
return { ...hooks, createElement, __reset: () => { cursor = 0; }, __state: state };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function memoryStorage(initial = {}) {
|
|
61
|
+
const data = { ...initial };
|
|
62
|
+
return {
|
|
63
|
+
getItem: (key) => (key in data ? data[key] : null),
|
|
64
|
+
setItem: (key, value) => {
|
|
65
|
+
data[key] = String(value);
|
|
66
|
+
},
|
|
67
|
+
removeItem: (key) => {
|
|
68
|
+
delete data[key];
|
|
69
|
+
},
|
|
70
|
+
_data: data
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Loads client.js the way the dsh module loader does and returns the plugin instance.
|
|
76
|
+
* The client is a browser bundle: it is evaluated with a fake window/document and the
|
|
77
|
+
* captured factory is invoked with a `require` that hands out the fake React.
|
|
78
|
+
*/
|
|
79
|
+
export function loadClient({ storage = memoryStorage(), language = "en", routes = {} } = {}) {
|
|
80
|
+
const source = readFileSync(path.join(root, "client.js"), "utf8");
|
|
81
|
+
let captured;
|
|
82
|
+
const head = { appendChild() {} };
|
|
83
|
+
const document = {
|
|
84
|
+
head,
|
|
85
|
+
createElement: () => ({ style: {}, set textContent(value) { this._text = value; }, appendChild() {} })
|
|
86
|
+
};
|
|
87
|
+
const window = {
|
|
88
|
+
localStorage: storage,
|
|
89
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
90
|
+
clearTimeout: (id) => clearTimeout(id)
|
|
91
|
+
};
|
|
92
|
+
const loader = { load: (module) => { captured = module; } };
|
|
93
|
+
window.__ModuleLoader__ = loader;
|
|
94
|
+
const evaluate = new Function("window", "document", "navigator", "console", source);
|
|
95
|
+
evaluate(window, document, { language }, console);
|
|
96
|
+
if (captured === undefined) throw new Error("client.js did not register a module");
|
|
97
|
+
const react = fakeReact();
|
|
98
|
+
const fetchStub = stubFetch(routes);
|
|
99
|
+
const instance = captured.factory((name) => {
|
|
100
|
+
if (name === "react") return react;
|
|
101
|
+
if (name === "react-dom") return { createPortal: (node) => node };
|
|
102
|
+
throw new Error(`the client requires an unexpected module: ${name}`);
|
|
103
|
+
});
|
|
104
|
+
return { instance, react, storage, window, document, moduleId: captured.id, fetchStub };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Collects the slot registrations a plugin performs, so a test can render a component. */
|
|
108
|
+
export function fakeSlotHost(react) {
|
|
109
|
+
const registrations = [];
|
|
110
|
+
const dictionaries = new Map();
|
|
111
|
+
const wrap = (component) => {
|
|
112
|
+
if (typeof component !== "function") return component;
|
|
113
|
+
return (props) => {
|
|
114
|
+
react?.__reset?.();
|
|
115
|
+
return component(props);
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
const ctx = {
|
|
119
|
+
slots: {
|
|
120
|
+
inject: (name, register) => {
|
|
121
|
+
const value = register();
|
|
122
|
+
registrations.push({ name, value });
|
|
123
|
+
return value;
|
|
124
|
+
},
|
|
125
|
+
register: (spec, component) => {
|
|
126
|
+
registrations.push({ name: spec.name, spec, component: wrap(component) });
|
|
127
|
+
return component;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
locale: {
|
|
131
|
+
register: (namespace, code, dictionary) => {
|
|
132
|
+
dictionaries.set(`${namespace}/${code}`, dictionary);
|
|
133
|
+
return () => {};
|
|
134
|
+
},
|
|
135
|
+
bind: (namespace) => (key) => {
|
|
136
|
+
for (const [id, dictionary] of dictionaries) {
|
|
137
|
+
if (id.startsWith(`${namespace}/`) && typeof dictionary[key] === "string") return dictionary[key];
|
|
138
|
+
}
|
|
139
|
+
return key;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
effect: (fn) => {
|
|
143
|
+
try {
|
|
144
|
+
return fn();
|
|
145
|
+
} catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
get: () => undefined
|
|
150
|
+
};
|
|
151
|
+
return { ctx, registrations, dictionaries };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Stubs the browser fetch the client uses for the host routes, so the client can
|
|
156
|
+
* exercise its settings and model catalog paths without a dsh host.
|
|
157
|
+
*/
|
|
158
|
+
export function stubFetch(routes = {}) {
|
|
159
|
+
const calls = [];
|
|
160
|
+
const original = globalThis.fetch;
|
|
161
|
+
const table = new Map(Object.entries(routes));
|
|
162
|
+
globalThis.fetch = async (input, init) => {
|
|
163
|
+
const url = String(input);
|
|
164
|
+
calls.push({ url, method: init?.method ?? "GET" });
|
|
165
|
+
const handler = table.get(url);
|
|
166
|
+
if (handler === undefined) {
|
|
167
|
+
return new Response("not found", { status: 404, headers: { "content-type": "text/plain" } });
|
|
168
|
+
}
|
|
169
|
+
const answer = typeof handler === "function" ? await handler(init) : handler;
|
|
170
|
+
const body = typeof answer === "string" ? answer : JSON.stringify(answer);
|
|
171
|
+
return new Response(body, { status: 200, headers: { "content-type": "application/json" } });
|
|
172
|
+
};
|
|
173
|
+
return {
|
|
174
|
+
calls,
|
|
175
|
+
set: (url, handler) => table.set(url, handler),
|
|
176
|
+
restore: () => {
|
|
177
|
+
globalThis.fetch = original;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Walks a rendered element tree and returns every node matching the predicate. */
|
|
183
|
+
export function walk(node, predicate, found = []) {
|
|
184
|
+
if (node === null || node === undefined || typeof node === "boolean") return found;
|
|
185
|
+
if (Array.isArray(node)) {
|
|
186
|
+
for (const item of node) walk(item, predicate, found);
|
|
187
|
+
return found;
|
|
188
|
+
}
|
|
189
|
+
if (typeof node !== "object") return found;
|
|
190
|
+
if (predicate(node)) found.push(node);
|
|
191
|
+
for (const child of node.children ?? []) walk(child, predicate, found);
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function textOf(node) {
|
|
196
|
+
if (node === null || node === undefined || typeof node === "boolean") return "";
|
|
197
|
+
if (typeof node === "string" || typeof node === "number") return String(node);
|
|
198
|
+
if (Array.isArray(node)) return node.map((item) => textOf(item)).join(" ");
|
|
199
|
+
return (node.children ?? []).map((item) => textOf(item)).join(" ");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export { root };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const suiteDir = path.join(here, "suite");
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
export const pkg = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
|
10
|
+
export const pluginId = pkg.name;
|
|
11
|
+
export const isDevCopy = pluginId.endsWith("-dev");
|
|
12
|
+
|
|
13
|
+
export const results = [];
|
|
14
|
+
let currentSuite = "";
|
|
15
|
+
|
|
16
|
+
export function suite(name) {
|
|
17
|
+
currentSuite = name;
|
|
18
|
+
results.push({ suite: name, cases: [] });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function test(name, fn) {
|
|
22
|
+
const entry = results.at(-1);
|
|
23
|
+
const caseEntry = { name, ok: true, error: undefined };
|
|
24
|
+
entry.cases.push(caseEntry);
|
|
25
|
+
try {
|
|
26
|
+
const outcome = fn();
|
|
27
|
+
if (outcome && typeof outcome.then === "function") {
|
|
28
|
+
return outcome.then(
|
|
29
|
+
() => {},
|
|
30
|
+
(error) => {
|
|
31
|
+
caseEntry.ok = false;
|
|
32
|
+
caseEntry.error = error;
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
caseEntry.ok = false;
|
|
38
|
+
caseEntry.error = error;
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function assert(condition, message) {
|
|
44
|
+
if (!condition) throw new Error(message ?? "assertion failed");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function assertEqual(actual, expected, message) {
|
|
48
|
+
const a = JSON.stringify(actual);
|
|
49
|
+
const b = JSON.stringify(expected);
|
|
50
|
+
if (a !== b) throw new Error(`${message ?? "values differ"}\n expected: ${b}\n actual: ${a}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function assertIncludes(haystack, needle, message) {
|
|
54
|
+
if (!String(haystack).includes(needle)) {
|
|
55
|
+
throw new Error(`${message ?? "missing substring"}: ${JSON.stringify(String(haystack).slice(0, 220))} does not include ${JSON.stringify(needle)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function assertMatch(value, pattern, message) {
|
|
60
|
+
if (!pattern.test(String(value))) {
|
|
61
|
+
throw new Error(`${message ?? "pattern mismatch"}: ${JSON.stringify(String(value).slice(0, 220))} does not match ${pattern}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function assertThrows(fn, message) {
|
|
66
|
+
let threw = false;
|
|
67
|
+
try {
|
|
68
|
+
fn();
|
|
69
|
+
} catch {
|
|
70
|
+
threw = true;
|
|
71
|
+
}
|
|
72
|
+
if (!threw) throw new Error(message ?? "expected a throw");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function run() {
|
|
76
|
+
const files = readdirSync(suiteDir).filter((name) => name.endsWith(".test.mjs")).sort();
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
await import(pathToFileURL(path.join(suiteDir, file)).href);
|
|
79
|
+
}
|
|
80
|
+
let passed = 0;
|
|
81
|
+
let failed = 0;
|
|
82
|
+
for (const entry of results) {
|
|
83
|
+
const failures = entry.cases.filter((item) => !item.ok);
|
|
84
|
+
passed += entry.cases.length - failures.length;
|
|
85
|
+
failed += failures.length;
|
|
86
|
+
const mark = failures.length === 0 ? "PASS" : "FAIL";
|
|
87
|
+
console.log(`${mark} ${entry.suite} (${entry.cases.length - failures.length}/${entry.cases.length})`);
|
|
88
|
+
for (const item of failures) {
|
|
89
|
+
const detail = item.error instanceof Error ? item.error.message : String(item.error);
|
|
90
|
+
console.log(` ✗ ${item.name}`);
|
|
91
|
+
for (const line of detail.split("\n")) console.log(` ${line}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
95
|
+
return failed === 0 ? 0 : 1;
|
|
96
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { suite, test, assert, assertEqual, assertIncludes, assertMatch } from "../harness.mjs";
|
|
2
|
+
import { loadClient, fakeSlotHost, stubFetch, walk, textOf, memoryStorage } from "../client-harness.mjs";
|
|
3
|
+
|
|
4
|
+
suite("client: rendering, dictionaries and persistence");
|
|
5
|
+
|
|
6
|
+
function settingsTree(client, props = {}) {
|
|
7
|
+
const { ctx, registrations } = fakeSlotHost(client.react);
|
|
8
|
+
client.instance.apply(ctx);
|
|
9
|
+
const settings = registrations.find((entry) => entry.spec?.name === "settings.section");
|
|
10
|
+
assert(settings !== undefined, "the settings section is registered");
|
|
11
|
+
return { tree: settings.component({ t: (key) => key, ...props }), ctx, registrations };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test("the client module registers itself under its own id", () => {
|
|
15
|
+
const { moduleId } = loadClient();
|
|
16
|
+
assertEqual(moduleId, "dsh-nextsteps");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("the client asks for the services it uses", () => {
|
|
20
|
+
const { instance } = loadClient();
|
|
21
|
+
assert(Array.isArray(instance.inject), "the client declares its inject list");
|
|
22
|
+
assert(instance.inject.includes("slots"), "the client needs the slots service");
|
|
23
|
+
assert(instance.inject.includes("locale"), "the client needs the locale service");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("the client registers the three dictionaries", () => {
|
|
27
|
+
const client = loadClient();
|
|
28
|
+
const { ctx, dictionaries } = fakeSlotHost(client.react);
|
|
29
|
+
client.instance.apply(ctx);
|
|
30
|
+
const codes = [...dictionaries.keys()].map((key) => key.split("/")[1]);
|
|
31
|
+
for (const lang of ["en", "pt-BR", "zh-CN"]) {
|
|
32
|
+
assert(codes.includes(lang), `the ${lang} dictionary is registered`);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("the settings section renders every control", () => {
|
|
37
|
+
const client = loadClient();
|
|
38
|
+
const fetchStub = stubFetch({
|
|
39
|
+
"/api/dsh-nextsteps.settings": { ok: true, settings: { lang: "auto", maxFollowups: 3, showHeader: true, model: "" }, version: "1.0.0" },
|
|
40
|
+
"/api/dsh-nextsteps.models": { ok: true, groups: [] },
|
|
41
|
+
"/api/dsh-nextsteps.update": { name: "dsh-nextsteps", current: "1.0.0", newer: false }
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
const { tree } = settingsTree(client);
|
|
45
|
+
const selects = walk(tree, (node) => node.type === "select");
|
|
46
|
+
const inputs = walk(tree, (node) => node.type === "input");
|
|
47
|
+
const buttons = walk(tree, (node) => node.type === "button");
|
|
48
|
+
assert(selects.length >= 1, "the settings page has a select");
|
|
49
|
+
assert(inputs.length >= 2, "the followup count and the header toggle are inputs");
|
|
50
|
+
assert(buttons.length >= 1, "the update row has its buttons");
|
|
51
|
+
} finally {
|
|
52
|
+
fetchStub.restore();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("the language select offers every language the host accepts", () => {
|
|
57
|
+
const client = loadClient();
|
|
58
|
+
const fetchStub = stubFetch({
|
|
59
|
+
"/api/dsh-nextsteps.settings": { ok: true, settings: { lang: "auto", maxFollowups: 3, showHeader: true, model: "" }, version: "1.0.0" },
|
|
60
|
+
"/api/dsh-nextsteps.models": { ok: true, groups: [] },
|
|
61
|
+
"/api/dsh-nextsteps.update": { name: "dsh-nextsteps", current: "1.0.0", newer: false }
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
const { tree } = settingsTree(client);
|
|
65
|
+
const languageSelect = walk(tree, (node) => node.type === "select" && node.props["aria-label"] === "setLanguage")[0];
|
|
66
|
+
assert(languageSelect !== undefined, "the language select is labelled");
|
|
67
|
+
const values = languageSelect.children.map((child) => child.props.value);
|
|
68
|
+
assertEqual(values.length, 16, "auto plus fifteen languages");
|
|
69
|
+
for (const expected of ["auto", "en", "pt-BR", "zh-CN", "es", "fr", "de", "ja", "ko", "ru", "ar", "hi", "tr", "vi"]) {
|
|
70
|
+
assert(values.includes(expected), `the language select offers ${expected}`);
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
fetchStub.restore();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the model row explains an empty catalog instead of waiting forever", () => {
|
|
78
|
+
const client = loadClient();
|
|
79
|
+
const fetchStub = stubFetch({
|
|
80
|
+
"/api/dsh-nextsteps.settings": { ok: true, settings: { lang: "auto", maxFollowups: 3, showHeader: true, model: "" }, version: "1.0.0" },
|
|
81
|
+
"/api/dsh-nextsteps.models": { ok: true, groups: [] },
|
|
82
|
+
"/api/dsh-nextsteps.update": { name: "dsh-nextsteps", current: "1.0.0", newer: false }
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
const { tree } = settingsTree(client);
|
|
86
|
+
const rows = walk(tree, (node) => node.type === "div" && typeof node.props.title === "string");
|
|
87
|
+
const modelRow = rows.find((node) => node.props.title === "setModelTip");
|
|
88
|
+
assert(modelRow !== undefined, "the model row is rendered");
|
|
89
|
+
const text = textOf(modelRow);
|
|
90
|
+
assert(text.length > 0, "the model row explains itself");
|
|
91
|
+
} finally {
|
|
92
|
+
fetchStub.restore();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("the followup count input keeps the bounds", () => {
|
|
97
|
+
const client = loadClient();
|
|
98
|
+
const fetchStub = stubFetch({
|
|
99
|
+
"/api/dsh-nextsteps.settings": { ok: true, settings: { lang: "auto", maxFollowups: 3, showHeader: true, model: "" }, version: "1.0.0" },
|
|
100
|
+
"/api/dsh-nextsteps.models": { ok: true, groups: [] },
|
|
101
|
+
"/api/dsh-nextsteps.update": { name: "dsh-nextsteps", current: "1.0.0", newer: false }
|
|
102
|
+
});
|
|
103
|
+
try {
|
|
104
|
+
const { tree } = settingsTree(client);
|
|
105
|
+
const count = walk(tree, (node) => node.type === "input" && node.props.type === "number")[0];
|
|
106
|
+
assert(count !== undefined, "the followup count is a number input");
|
|
107
|
+
assertEqual(count.props.min, 1);
|
|
108
|
+
assertEqual(count.props.max, 6);
|
|
109
|
+
} finally {
|
|
110
|
+
fetchStub.restore();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("the turn tail and the tool view are registered as slots", () => {
|
|
115
|
+
const client = loadClient();
|
|
116
|
+
const { ctx, registrations } = fakeSlotHost(client.react);
|
|
117
|
+
client.instance.apply(ctx);
|
|
118
|
+
const names = registrations.map((entry) => entry.spec?.name ?? entry.name);
|
|
119
|
+
assert(names.includes("conversation.chat.turnTail"), "the tail slot is registered");
|
|
120
|
+
assert(names.includes("tool.call.toolview"), "the tool view slot is registered");
|
|
121
|
+
assert(names.includes("settings.section"), "the settings section is registered");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("the captures survive a reload of the client", () => {
|
|
125
|
+
const storage = memoryStorage();
|
|
126
|
+
const client = loadClient({ storage });
|
|
127
|
+
const { ctx, registrations } = fakeSlotHost(client.react);
|
|
128
|
+
client.instance.apply(ctx);
|
|
129
|
+
const capture = registrations.find((entry) => entry.spec?.name === "tool.call.toolview");
|
|
130
|
+
assert(capture !== undefined, "the capture is registered");
|
|
131
|
+
capture.component({
|
|
132
|
+
sessionId: "s1",
|
|
133
|
+
callId: "call-1",
|
|
134
|
+
block: {
|
|
135
|
+
kind: "tool-result",
|
|
136
|
+
callId: "call-1",
|
|
137
|
+
name: "suggest_followups",
|
|
138
|
+
content: [{ type: "text", text: '{"rev":"sf-2","followups":[{"prompt":"Rodar o build","label":"Rodar build"}]}' }]
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
const key = Object.keys(storage._data).find((name) => name.includes("captures"));
|
|
142
|
+
assert(key !== undefined, "the captures are stored under a plugin owned key");
|
|
143
|
+
assert(key.includes("dsh-nextsteps"), "the captures key belongs to the plugin");
|
|
144
|
+
const parsed = JSON.parse(storage._data[key]);
|
|
145
|
+
const entry = parsed.s1["call-1"];
|
|
146
|
+
assert(entry !== undefined, "the capture is stored under its session and call");
|
|
147
|
+
assertEqual(entry.followups[0].label, "Rodar build");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a capture without a turn keeps no turn", () => {
|
|
151
|
+
const storage = memoryStorage();
|
|
152
|
+
const client = loadClient({ storage });
|
|
153
|
+
const { ctx, registrations } = fakeSlotHost(client.react);
|
|
154
|
+
client.instance.apply(ctx);
|
|
155
|
+
const capture = registrations.find((entry) => entry.spec?.name === "tool.call.toolview");
|
|
156
|
+
capture.component({
|
|
157
|
+
sessionId: "s2",
|
|
158
|
+
callId: "call-2",
|
|
159
|
+
block: {
|
|
160
|
+
kind: "tool-result",
|
|
161
|
+
callId: "call-2",
|
|
162
|
+
name: "suggest_followups",
|
|
163
|
+
content: [{ type: "text", text: '{"rev":"sf-2","followups":[{"prompt":"p2","label":"l2"}]}' }]
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
const key = Object.keys(storage._data).find((name) => name.includes("captures"));
|
|
167
|
+
const parsed = JSON.parse(storage._data[key]);
|
|
168
|
+
assert(parsed.s2["call-2"].turn === undefined, "a capture without a turn keeps no turn");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("the tail renders no card for a turn without a capture", () => {
|
|
172
|
+
const client = loadClient();
|
|
173
|
+
const fetchStub = stubFetch({});
|
|
174
|
+
const { ctx, registrations } = fakeSlotHost(client.react);
|
|
175
|
+
client.instance.apply(ctx);
|
|
176
|
+
const tail = registrations.find((entry) => entry.spec?.name === "conversation.chat.turnTail");
|
|
177
|
+
assert(tail !== undefined, "the tail is registered");
|
|
178
|
+
const tree = tail.component({ sessionId: "s1", turn: { turn: 3, end: { seq: 12 } }, t: (key) => key });
|
|
179
|
+
const cards = walk(tree, (node) => String(node.props?.className ?? "").includes("dsh-sf-card"));
|
|
180
|
+
assertEqual(cards.length, 0, "a turn without a capture renders no card");
|
|
181
|
+
fetchStub.restore();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("the client asks the host for its settings and its model catalog", async () => {
|
|
185
|
+
const client = loadClient({ routes: {
|
|
186
|
+
"/api/dsh-nextsteps.settings": { ok: true, settings: { lang: "auto", maxFollowups: 3, showHeader: true, model: "" }, version: "1.0.0" },
|
|
187
|
+
"/api/dsh-nextsteps.models": { ok: true, groups: [] },
|
|
188
|
+
"/api/dsh-nextsteps.update": { name: "dsh-nextsteps", current: "1.0.0", newer: false }
|
|
189
|
+
} });
|
|
190
|
+
try {
|
|
191
|
+
const { ctx } = fakeSlotHost(client.react);
|
|
192
|
+
client.instance.apply(ctx);
|
|
193
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
194
|
+
const urls = fetchStub.calls.map((call) => call.url);
|
|
195
|
+
assert(urls.some((url) => url.includes("/settings")), "the client reads the settings");
|
|
196
|
+
assert(urls.some((url) => url.includes("/models")), "the client reads the model catalog");
|
|
197
|
+
assert(urls.every((url) => url.startsWith("/api/dsh-nextsteps.")), "the client only calls its own routes");
|
|
198
|
+
} finally {
|
|
199
|
+
fetchStub.restore();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("the client survives a host that answers with an empty body", async () => {
|
|
204
|
+
const client = loadClient();
|
|
205
|
+
const fetchStub = stubFetch({
|
|
206
|
+
"/api/dsh-nextsteps.settings": "",
|
|
207
|
+
"/api/dsh-nextsteps.models": "",
|
|
208
|
+
"/api/dsh-nextsteps.update": ""
|
|
209
|
+
});
|
|
210
|
+
try {
|
|
211
|
+
const { ctx } = fakeSlotHost(client.react);
|
|
212
|
+
client.instance.apply(ctx);
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
214
|
+
assert(true, "an empty answer does not break the client");
|
|
215
|
+
} finally {
|
|
216
|
+
fetchStub.restore();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { suite, test, assert, assertEqual, assertIncludes, pluginId } from "../harness.mjs";
|
|
4
|
+
import { root } from "../client-harness.mjs";
|
|
5
|
+
|
|
6
|
+
suite("contract: host and client agree");
|
|
7
|
+
|
|
8
|
+
const clientSource = readFileSync(path.join(root, "client.js"), "utf8");
|
|
9
|
+
const hostSource = readFileSync(path.join(root, "index.js"), "utf8");
|
|
10
|
+
const escaped = pluginId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11
|
+
const routePattern = new RegExp(`'/api/${escaped}\\.([a-z]+)'`, "g");
|
|
12
|
+
|
|
13
|
+
const CLIENT_ROUTES = [...clientSource.matchAll(routePattern)].map((match) => match[1]);
|
|
14
|
+
const HOST_ROUTES = [...hostSource.matchAll(routePattern)].map((match) => match[1]);
|
|
15
|
+
|
|
16
|
+
test("every route the client calls is registered by the host", () => {
|
|
17
|
+
assert(CLIENT_ROUTES.length > 0, "the client calls host routes");
|
|
18
|
+
for (const route of CLIENT_ROUTES) {
|
|
19
|
+
assert(HOST_ROUTES.includes(route), `the host registers the ${route} route`);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("the host registers no route the client never calls", () => {
|
|
24
|
+
for (const route of HOST_ROUTES) {
|
|
25
|
+
assert(CLIENT_ROUTES.includes(route), `the client calls the ${route} route`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("the settings the client sends are the ones the host reads", () => {
|
|
30
|
+
const clientSends = [...clientSource.matchAll(/save\(\{ ([a-zA-Z]+):/g)].map((match) => match[1]);
|
|
31
|
+
assert(clientSends.length > 0, "the client sends settings");
|
|
32
|
+
for (const key of clientSends) {
|
|
33
|
+
assert(hostSource.includes(`${key}:`), `the host reads the ${key} setting`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("the settings the host stores are the ones the client renders", () => {
|
|
38
|
+
const hostStores = [...hostSource.matchAll(/^\s{6}(lang|maxFollowups|showHeader|model):/gm)].map((match) => match[1]);
|
|
39
|
+
for (const key of new Set(hostStores)) {
|
|
40
|
+
assert(clientSource.includes(key), `the client renders the ${key} setting`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the plugin identity is the same on both halves", () => {
|
|
45
|
+
assertIncludes(clientSource, `id: '${pluginId}',`, "the client registers the plugin id");
|
|
46
|
+
assertIncludes(hostSource, `const name = '${pluginId}';`, "the host registers the plugin name");
|
|
47
|
+
assertIncludes(hostSource, `const NPM_NAME = '${pluginId}';`, "the host watches the npm package");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("the storage keys belong to the plugin", () => {
|
|
51
|
+
const keys = [...clientSource.matchAll(/['"](dsh[.-][a-z0-9.-]+)['"]/g)].map((match) => match[1]);
|
|
52
|
+
const owned = [...new Set(keys)].filter((key) => key.includes("suggest-followups") || key.includes("nextsteps"));
|
|
53
|
+
assert(owned.length >= 2, "the client owns the clicked and the captures storage");
|
|
54
|
+
for (const key of owned) {
|
|
55
|
+
assert(key.includes(pluginId), `the storage key belongs to the plugin: ${key}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the client tolerates any payload revision", () => {
|
|
60
|
+
const hostRevision = hostSource.match(/PLUGIN_REV = '([a-z0-9-]+)'/)?.[1];
|
|
61
|
+
assert(hostRevision !== undefined, "the host declares a payload revision");
|
|
62
|
+
assert(clientSource.includes("rev"), "the client reads the payload revision");
|
|
63
|
+
assert(!clientSource.includes(`rev: '${hostRevision}'`), "the client does not pin one revision");
|
|
64
|
+
});
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { readFileSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { suite, test, assert, assertEqual, assertIncludes, assertMatch, pluginId, isDevCopy } from "../harness.mjs";
|
|
5
|
+
import { root } from "../client-harness.mjs";
|
|
6
|
+
|
|
7
|
+
suite("host: tool, settings and routes");
|
|
8
|
+
|
|
9
|
+
const moduleUrl = new URL(`../../index.js?test=${Date.now()}`, import.meta.url);
|
|
10
|
+
|
|
11
|
+
function stubContext({ services, llmModels, llmText } = {}) {
|
|
12
|
+
const defaults = { systemPrompt: { section: (spec) => sections.push(spec) } };
|
|
13
|
+
const resolved = { ...defaults, ...services };
|
|
14
|
+
const warnings = [];
|
|
15
|
+
const originalWarn = console.warn;
|
|
16
|
+
console.warn = (...args) => { warnings.push(args.join(" ")); };
|
|
17
|
+
const registered = [];
|
|
18
|
+
const routes = new Map();
|
|
19
|
+
const sections = [];
|
|
20
|
+
const ctx = {
|
|
21
|
+
tools: {
|
|
22
|
+
register: (spec) => {
|
|
23
|
+
registered.push(spec);
|
|
24
|
+
return () => {};
|
|
25
|
+
},
|
|
26
|
+
unregister: () => {}
|
|
27
|
+
},
|
|
28
|
+
connection: { fetch: { register: (route) => routes.set(route.path, route) } },
|
|
29
|
+
effect: (fn) => {
|
|
30
|
+
try {
|
|
31
|
+
return fn();
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
get: (name) => resolved[name]
|
|
37
|
+
};
|
|
38
|
+
if (llmModels !== undefined || llmText !== undefined) {
|
|
39
|
+
ctx.get = (name) => {
|
|
40
|
+
if (name === "llm") {
|
|
41
|
+
return {
|
|
42
|
+
listProviders: () => [{ id: "demo", name: "Demo" }],
|
|
43
|
+
listModels: async () => llmModels ?? [{ id: "demo-model", name: "Demo Model" }],
|
|
44
|
+
prepareCall: async (route) => ({
|
|
45
|
+
config: { provider: route.provider, model: route.model },
|
|
46
|
+
stream: async function* () {
|
|
47
|
+
yield { type: "text-delta", text: llmText ?? '{"followups":[{"prompt":"p","label":"l"}]}' };
|
|
48
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return resolved[name];
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
console.warn = originalWarn;
|
|
57
|
+
return { ctx, registered, routes, sections, warnings };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function settingsFileFor(name) {
|
|
61
|
+
const dir = mkdtempSync(path.join(tmpdir(), "dsh-nextsteps-test-"));
|
|
62
|
+
const file = path.join(dir, `${name}.json`);
|
|
63
|
+
return { dir, file };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("the tool registers with the schema the model needs", async () => {
|
|
67
|
+
const { ctx, registered } = stubContext();
|
|
68
|
+
const mod = await import(moduleUrl.href);
|
|
69
|
+
await mod.apply(ctx);
|
|
70
|
+
assert(registered.length === 1, "exactly one tool is registered");
|
|
71
|
+
const tool = registered[0];
|
|
72
|
+
assertEqual(tool.name, "suggest_followups");
|
|
73
|
+
assert(tool.parameters.properties.followups.items.required.includes("prompt"), "each followup requires a prompt");
|
|
74
|
+
assertIncludes(tool.description, "BEFORE writing your final answer", "the tool must be called before the final answer");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the description survives a host without a system prompt service", async () => {
|
|
78
|
+
const { ctx, registered } = stubContext();
|
|
79
|
+
const mod = await import(moduleUrl.href);
|
|
80
|
+
await mod.apply(ctx);
|
|
81
|
+
const description = registered[0].description;
|
|
82
|
+
assertIncludes(description, "when there is no such section, use the language of the conversation", "the description must fall back to the conversation language");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("the bundle does not require optional host services to load", async () => {
|
|
86
|
+
const source = readFileSync(path.join(root, "index.js"), "utf8");
|
|
87
|
+
const injectLine = source.split("\n").find((line) => line.startsWith("const inject ="));
|
|
88
|
+
assertMatch(injectLine, /'tools'/, "the tools service is required");
|
|
89
|
+
assert(!injectLine.includes("sessionQuery"), "sessionQuery must not be required to load the bundle");
|
|
90
|
+
assert(!injectLine.includes("systemPrompt"), "systemPrompt must not be required to load the bundle");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("every route declares a buffered request body", async () => {
|
|
94
|
+
const { ctx, routes } = stubContext();
|
|
95
|
+
const mod = await import(moduleUrl.href);
|
|
96
|
+
await mod.apply(ctx);
|
|
97
|
+
assert(routes.size >= 4, "the plugin registers its routes");
|
|
98
|
+
for (const [routePath, route] of routes) {
|
|
99
|
+
assertEqual(route.requestBody, "buffered", `${routePath} must declare a buffered request body`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the settings route answers with the current settings and the version", async () => {
|
|
104
|
+
const { ctx, routes } = stubContext();
|
|
105
|
+
const mod = await import(moduleUrl.href);
|
|
106
|
+
await mod.apply(ctx);
|
|
107
|
+
const response = await routes.get(`/api/${pluginId}.settings`).fetch(new Request("http://x"));
|
|
108
|
+
const body = await response.json();
|
|
109
|
+
assertEqual(response.status, 200);
|
|
110
|
+
assertEqual(body.ok, true);
|
|
111
|
+
assert(body.version !== undefined, "the settings route reports the version");
|
|
112
|
+
assert(body.settings.lang !== undefined && body.settings.model !== undefined, "the settings carry the language and the model");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("the settings route rejects a language it does not know", async () => {
|
|
116
|
+
const { ctx, routes } = stubContext();
|
|
117
|
+
const mod = await import(moduleUrl.href);
|
|
118
|
+
await mod.apply(ctx);
|
|
119
|
+
const route = routes.get(`/api/${pluginId}.settings`);
|
|
120
|
+
const before = await (await route.fetch(new Request("http://x"))).json();
|
|
121
|
+
const response = await route.fetch(new Request("http://x", {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify({ lang: "klingon" })
|
|
125
|
+
}));
|
|
126
|
+
const after = await response.json();
|
|
127
|
+
assertEqual(after.settings.lang, before.settings.lang, "an unknown language keeps the current one");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("the settings route keeps a model as provider/model", async () => {
|
|
131
|
+
const { ctx, routes } = stubContext();
|
|
132
|
+
const mod = await import(moduleUrl.href);
|
|
133
|
+
await mod.apply(ctx);
|
|
134
|
+
const route = routes.get(`/api/${pluginId}.settings`);
|
|
135
|
+
const response = await route.fetch(new Request("http://x", {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { "content-type": "application/json" },
|
|
138
|
+
body: JSON.stringify({ model: "demo/demo-model" })
|
|
139
|
+
}));
|
|
140
|
+
const body = await response.json();
|
|
141
|
+
assertEqual(body.settings.model, "demo/demo-model");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("the model catalog lists the providers the host offers", async () => {
|
|
145
|
+
const { ctx, routes } = stubContext({ llmModels: [{ id: "demo-model", name: "Demo Model" }] });
|
|
146
|
+
const mod = await import(moduleUrl.href);
|
|
147
|
+
await mod.apply(ctx);
|
|
148
|
+
const response = await routes.get(`/api/${pluginId}.models`).fetch(new Request("http://x"));
|
|
149
|
+
const body = await response.json();
|
|
150
|
+
assertEqual(body.ok, true);
|
|
151
|
+
assertEqual(body.groups.length, 1);
|
|
152
|
+
assertEqual(body.groups[0].models[0].id, "demo-model");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("the model catalog stays empty instead of failing when the host offers no models", async () => {
|
|
156
|
+
const { ctx, routes } = stubContext();
|
|
157
|
+
const mod = await import(moduleUrl.href);
|
|
158
|
+
await mod.apply(ctx);
|
|
159
|
+
const response = await routes.get(`/api/${pluginId}.models`).fetch(new Request("http://x"));
|
|
160
|
+
const body = await response.json();
|
|
161
|
+
assertEqual(response.status, 200);
|
|
162
|
+
assertEqual(body.groups, []);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("the system prompt section states the language and the count", async () => {
|
|
166
|
+
const { ctx, sections } = stubContext();
|
|
167
|
+
const mod = await import(moduleUrl.href);
|
|
168
|
+
await mod.apply(ctx);
|
|
169
|
+
assert(sections.length === 1, "one system prompt section");
|
|
170
|
+
const text = sections[0].text();
|
|
171
|
+
assertIncludes(text, "nextsteps", "the section names itself");
|
|
172
|
+
assertIncludes(text, "followup prompts", "the section explains the count");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("the generation route answers with the suggestions of the chosen model", async () => {
|
|
176
|
+
const events = [
|
|
177
|
+
{ seq: 1, type: "user/message", data: { content: [{ type: "text", text: "me ajuda com o build" }], source: { kind: "user" } } },
|
|
178
|
+
{ seq: 9, type: "turn/end", data: { turn: 1, reason: { kind: "completed" } } }
|
|
179
|
+
];
|
|
180
|
+
const sessionQuery = { observeSession: async () => ({ events, [Symbol.dispose]: () => {} }) };
|
|
181
|
+
const { ctx, routes } = stubContext({
|
|
182
|
+
services: { sessionQuery },
|
|
183
|
+
llmText: '{"followups":[{"prompt":"Rodar o build","label":"Rodar build"}]}'
|
|
184
|
+
});
|
|
185
|
+
const mod = await import(moduleUrl.href);
|
|
186
|
+
await mod.apply(ctx);
|
|
187
|
+
const response = await routes.get(`/api/${pluginId}.generate`).fetch(new Request("http://x", {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { "content-type": "application/json" },
|
|
190
|
+
body: JSON.stringify({ sessionId: "s1", afterSeq: 9 })
|
|
191
|
+
}));
|
|
192
|
+
const body = await response.json();
|
|
193
|
+
assertEqual(response.status, 200);
|
|
194
|
+
assertEqual(body.followups.length, 1);
|
|
195
|
+
assertEqual(body.followups[0].label, "Rodar build");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("the generation route reads the real session event shape", async () => {
|
|
199
|
+
let seen = "";
|
|
200
|
+
const events = [
|
|
201
|
+
{ seq: 1, type: "user/message", data: { content: [{ type: "text", text: "pergunta do usuario" }], source: { kind: "user" } } },
|
|
202
|
+
{ seq: 2, type: "user/message", data: { content: [{ type: "text", text: "<system-reminder>ruído</system-reminder>" }], source: { kind: "time-context" } } },
|
|
203
|
+
{ seq: 3, type: "assistant/message", data: { message: { content: [{ type: "text", text: "resposta do assistente" }] } } },
|
|
204
|
+
{ seq: 9, type: "turn/end", data: { turn: 1, reason: { kind: "completed" } } }
|
|
205
|
+
];
|
|
206
|
+
const sessionQuery = {
|
|
207
|
+
observeSession: async () => ({
|
|
208
|
+
events,
|
|
209
|
+
[Symbol.dispose]: () => {}
|
|
210
|
+
})
|
|
211
|
+
};
|
|
212
|
+
const llm = {
|
|
213
|
+
listProviders: () => [{ id: "demo", name: "Demo" }],
|
|
214
|
+
listModels: async () => [{ id: "demo-model", name: "Demo Model" }],
|
|
215
|
+
prepareCall: async (route) => ({
|
|
216
|
+
config: route,
|
|
217
|
+
stream: async function* () {
|
|
218
|
+
seen = route.prompt ?? "";
|
|
219
|
+
yield { type: "text-delta", text: '{"followups":[{"prompt":"p","label":"l"}]}' };
|
|
220
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
};
|
|
224
|
+
const { ctx, routes } = stubContext({ services: { sessionQuery } });
|
|
225
|
+
ctx.get = (name) => (name === "llm" ? llm : name === "sessionQuery" ? sessionQuery : undefined);
|
|
226
|
+
const mod = await import(moduleUrl.href);
|
|
227
|
+
await mod.apply(ctx);
|
|
228
|
+
const response = await routes.get(`/api/${pluginId}.generate`).fetch(new Request("http://x", {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "content-type": "application/json" },
|
|
231
|
+
body: JSON.stringify({ sessionId: "s1", afterSeq: 9 })
|
|
232
|
+
}));
|
|
233
|
+
assertEqual(response.status, 200);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("the generation route refuses a body it cannot use", async () => {
|
|
237
|
+
const { ctx, routes } = stubContext();
|
|
238
|
+
const mod = await import(moduleUrl.href);
|
|
239
|
+
await mod.apply(ctx);
|
|
240
|
+
const route = routes.get(`/api/${pluginId}.generate`);
|
|
241
|
+
const missing = await route.fetch(new Request("http://x", {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: { "content-type": "application/json" },
|
|
244
|
+
body: JSON.stringify({ afterSeq: 3 })
|
|
245
|
+
}));
|
|
246
|
+
assertEqual(missing.status, 400);
|
|
247
|
+
const broken = await route.fetch(new Request("http://x", {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { "content-type": "application/json" },
|
|
250
|
+
body: "not json"
|
|
251
|
+
}));
|
|
252
|
+
assertEqual(broken.status, 400);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("the generation route answers cleanly when the host has no session query service", async () => {
|
|
256
|
+
const { ctx, routes } = stubContext();
|
|
257
|
+
const mod = await import(moduleUrl.href);
|
|
258
|
+
await mod.apply(ctx);
|
|
259
|
+
const response = await routes.get(`/api/${pluginId}.generate`).fetch(new Request("http://x", {
|
|
260
|
+
method: "POST",
|
|
261
|
+
headers: { "content-type": "application/json" },
|
|
262
|
+
body: JSON.stringify({ sessionId: "s1", afterSeq: 3 })
|
|
263
|
+
}));
|
|
264
|
+
assertEqual(response.status, 502);
|
|
265
|
+
const body = await response.json();
|
|
266
|
+
assertIncludes(body.error, "sessionQuery", "the error names the missing service");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("the update route reports the current version", async () => {
|
|
270
|
+
const release = { tag_name: "v9.9.9", html_url: "https://github.com/klaus-2/dsh-nextsteps/releases/tag/v9.9.9", body: "notes" };
|
|
271
|
+
const original = globalThis.fetch;
|
|
272
|
+
globalThis.fetch = async (input) => {
|
|
273
|
+
const url = String(input);
|
|
274
|
+
if (url.includes("api.github.com")) return new Response(JSON.stringify(release), { status: 200, headers: { "content-type": "application/json" } });
|
|
275
|
+
if (url.includes("registry.npmjs.org")) return new Response(JSON.stringify({ version: "9.9.9" }), { status: 200, headers: { "content-type": "application/json" } });
|
|
276
|
+
return original(input);
|
|
277
|
+
};
|
|
278
|
+
try {
|
|
279
|
+
const { ctx, routes } = stubContext();
|
|
280
|
+
const mod = await import(moduleUrl.href);
|
|
281
|
+
await mod.apply(ctx);
|
|
282
|
+
const response = await routes.get(`/api/${pluginId}.update`).fetch(new Request("http://x"));
|
|
283
|
+
const body = await response.json();
|
|
284
|
+
assertEqual(response.status, 200);
|
|
285
|
+
assertEqual(body.name, pluginId);
|
|
286
|
+
assert(body.current !== undefined, "the update route reports the current version");
|
|
287
|
+
assertEqual(body.newer, true, "a newer release is reported");
|
|
288
|
+
} finally {
|
|
289
|
+
globalThis.fetch = original;
|
|
290
|
+
}
|
|
291
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { suite, test, assert, assertEqual, assertIncludes, assertMatch, pkg, pluginId, isDevCopy } from "../harness.mjs";
|
|
4
|
+
import { root } from "../client-harness.mjs";
|
|
5
|
+
|
|
6
|
+
suite("package: layout, dictionaries and static safety");
|
|
7
|
+
|
|
8
|
+
const clientSource = readFileSync(path.join(root, "client.js"), "utf8");
|
|
9
|
+
const hostSource = readFileSync(path.join(root, "index.js"), "utf8");
|
|
10
|
+
const locales = ["en", "pt-BR", "zh-CN"].map((lang) => ({
|
|
11
|
+
lang,
|
|
12
|
+
data: JSON.parse(readFileSync(path.join(root, "lib", "locale", `${lang}.json`), "utf8"))
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
test("the entrypoints live at the package root", () => {
|
|
16
|
+
assert(exists(path.join(root, "index.js")), "index.js is at the root");
|
|
17
|
+
assert(exists(path.join(root, "client.js")), "client.js is at the root");
|
|
18
|
+
assertEqual(pkg.exports["."], "./index.js");
|
|
19
|
+
assertEqual(pkg.exports["./client"], "./client.js");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("every packaged file exists", () => {
|
|
23
|
+
for (const entry of pkg.files) {
|
|
24
|
+
const target = path.join(root, entry);
|
|
25
|
+
const present = exists(target) || existsDirectory(target);
|
|
26
|
+
assert(present, `the packaged file ${entry} exists`);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("the package carries the identity it publishes under", () => {
|
|
31
|
+
assert(pkg.name === pluginId, "the package name is the plugin identity");
|
|
32
|
+
assertEqual(pkg.exports["."], "./index.js");
|
|
33
|
+
assertMatch(pkg.name, /^[a-z0-9][a-z0-9-]*$/, "the package name is npm safe");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("the release copy is publishable and the development copy is not", () => {
|
|
37
|
+
if (isDevCopy) {
|
|
38
|
+
assertEqual(pkg.private, true, "a development copy is never published");
|
|
39
|
+
assertMatch(pkg.version, /-dev\.\d+$/, "a development copy carries a development version");
|
|
40
|
+
} else {
|
|
41
|
+
assert(pkg.private !== true, "the published package is not private");
|
|
42
|
+
assertMatch(pkg.version, /^\d+\.\d+\.\d+$/, "the published version is semver");
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("the sources carry no identity from another variant", () => {
|
|
47
|
+
const foreign = isDevCopy ? "dsh-nextsteps-dev" : "dsh-nextsteps-dev";
|
|
48
|
+
for (const [name, source] of [["index.js", hostSource], ["client.js", clientSource]]) {
|
|
49
|
+
const identity = isDevCopy ? "dsh-nextsteps-dev" : "dsh-nextsteps";
|
|
50
|
+
assert(source.includes(identity), `${name} carries its own identity`);
|
|
51
|
+
assert(!source.includes(foreign === identity ? "dsh-nextsteps-dev-officially" : foreign), `${name} carries no foreign identity`);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("the release check points at the releases of this repository", () => {
|
|
56
|
+
if (isDevCopy) {
|
|
57
|
+
assert(hostSource.includes("about:blank"), "a development copy does not poll the release feed");
|
|
58
|
+
} else {
|
|
59
|
+
assert(hostSource.includes("/releases/latest"), "the host watches a release feed");
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("the client does not require a json file", () => {
|
|
64
|
+
const requires = clientSource.match(/require\(([^)]*)\)/g) ?? [];
|
|
65
|
+
for (const call of requires) {
|
|
66
|
+
assert(!call.includes(".json"), `the client must not require a json file: ${call}`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("the client does not depend on a path outside the package", () => {
|
|
71
|
+
assert(!clientSource.includes("C:/"), "the client has no absolute windows path");
|
|
72
|
+
assert(!clientSource.includes("C:\\\\"), "the client has no absolute windows path");
|
|
73
|
+
assert(!/require\(['"]\.\.\//.test(clientSource), "the client requires nothing outside the package");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the host keeps its settings inside the dsh home", () => {
|
|
77
|
+
assertIncludes(hostSource, "homedir()", "the host resolves the dsh home");
|
|
78
|
+
assert(!/['"]C:[\\/]/.test(hostSource), "the host has no absolute path");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("the dictionaries carry the same keys", () => {
|
|
82
|
+
const [reference, ...others] = locales;
|
|
83
|
+
const referenceKeys = Object.keys(reference.data).sort();
|
|
84
|
+
for (const other of others) {
|
|
85
|
+
assertEqual(Object.keys(other.data).sort(), referenceKeys, `${other.lang} carries the same keys as ${reference.lang}`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("every key the client asks for exists in every dictionary", () => {
|
|
90
|
+
const used = new Set();
|
|
91
|
+
for (const match of clientSource.matchAll(/\bt\('([a-zA-Z.]+)'\)/g)) used.add(match[1]);
|
|
92
|
+
assert(used.size > 0, "the client asks for translated strings");
|
|
93
|
+
for (const { lang, data } of locales) {
|
|
94
|
+
for (const key of used) {
|
|
95
|
+
assert(typeof data[key] === "string", `${lang} translates ${key}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("every dictionary key is used by the client", () => {
|
|
101
|
+
const used = new Set();
|
|
102
|
+
for (const match of clientSource.matchAll(/\bt\('([a-zA-Z.]+)'\)/g)) used.add(match[1]);
|
|
103
|
+
for (const { lang, data } of locales) {
|
|
104
|
+
for (const key of Object.keys(data)) {
|
|
105
|
+
if (key === "meta") continue;
|
|
106
|
+
assert(used.has(key), `${lang} translates ${key}, which the client asks for`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("the dictionaries declare their plugin metadata", () => {
|
|
112
|
+
for (const { lang, data } of locales) {
|
|
113
|
+
assert(data.meta !== undefined, `${lang} declares meta`);
|
|
114
|
+
assert(typeof data.meta.title === "string" && data.meta.title !== "", `${lang} declares a title`);
|
|
115
|
+
assert(typeof data.meta.description === "string" && data.meta.description !== "", `${lang} declares a description`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("the readmes cover the three languages", () => {
|
|
120
|
+
for (const file of ["README.md", "README.pt-BR.md", "README.zh-CN.md"]) {
|
|
121
|
+
assert(exists(path.join(root, file)), `${file} exists`);
|
|
122
|
+
}
|
|
123
|
+
assertIncludes(readFileSync(path.join(root, "README.md"), "utf8"), "README.pt-BR.md", "the english readme links the others");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("the plugin declares the client services it needs", () => {
|
|
127
|
+
assert(pkg.dsh?.client?.platform === "web", "the client runs on the web platform");
|
|
128
|
+
assert(Array.isArray(pkg.dsh?.client?.inject), "the client inject list is declared");
|
|
129
|
+
assert(pkg.dsh?.client?.inject.includes("@deepseek-ai/dsh-client-locale"), "the client asks for the locale service");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("the plugin declares its bundle patch", () => {
|
|
133
|
+
assert(pkg.dsh?.bundle?.patch === "./cordis.patch.yml", "the bundle patch is declared");
|
|
134
|
+
const patch = readFileSync(path.join(root, "cordis.patch.yml"), "utf8");
|
|
135
|
+
assertIncludes(patch, `id: ${pluginId}`, "the patch registers the plugin");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("the license and the readme are packaged", () => {
|
|
139
|
+
assert(pkg.files.includes("LICENSE"), "the license is packaged");
|
|
140
|
+
assert(pkg.files.includes("README.md"), "the readme is packaged");
|
|
141
|
+
assert(pkg.license !== undefined, "the license is declared");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("the regression suite is packaged and runnable", () => {
|
|
145
|
+
assert(pkg.scripts?.test !== undefined, "the package declares a test script");
|
|
146
|
+
assert(pkg.files.includes("scripts"), "the suite ships with the package");
|
|
147
|
+
assert(exists(path.join(root, "scripts", "test.mjs")), "the suite entrypoint exists");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
function existsDirectory(target) {
|
|
151
|
+
try {
|
|
152
|
+
return readdirSync(target).length >= 0;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function exists(target) {
|
|
159
|
+
try {
|
|
160
|
+
readFileSync(target);
|
|
161
|
+
return true;
|
|
162
|
+
} catch {
|
|
163
|
+
try {
|
|
164
|
+
return readFileSync(path.join(target, "package.json")) !== undefined;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
package/scripts/test.mjs
ADDED