mirror of
https://gh-proxy.org/https://github.com/klinsmaya/workdsh.git
synced 2026-11-09 18:07:02 +08:00
321 lines
17 KiB
JavaScript
321 lines
17 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { chromium, expect } from '@playwright/test';
|
|
import {
|
|
SHIPPED_PRESET_ROOT,
|
|
copyComposition,
|
|
discoverPresets,
|
|
} from '@deepseek-ai/dsh-agent-presets';
|
|
|
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
mkdirSync(resolve(root, '.test-runtime'), { recursive: true });
|
|
mkdirSync(resolve(root, '.artifacts'), { recursive: true });
|
|
const dshHome = mkdtempSync(resolve(root, '.test-runtime/presets-'));
|
|
const userRoot = resolve(dshHome, '.agent-presets');
|
|
const presetRoots = [
|
|
{ path: SHIPPED_PRESET_ROOT, trust: 'system' },
|
|
{ path: userRoot, trust: 'user' },
|
|
];
|
|
const env = {
|
|
...process.env,
|
|
DSH_HOME: dshHome,
|
|
PATH: `${resolve(root, 'node_modules/.bin')}:${dirname(process.execPath)}:${process.env.PATH}`,
|
|
};
|
|
delete env.DEEPSEEK_API_KEY;
|
|
delete env.OPENAI_API_KEY;
|
|
delete env.ANTHROPIC_API_KEY;
|
|
// Test-only bootstrap for an unrelated prerequisite. Runtime workspace/session
|
|
// behavior after boot is still provided by the published Harness services.
|
|
const workspaceId = randomUUID();
|
|
const now = new Date().toISOString();
|
|
mkdirSync(resolve(dshHome, 'storages'), { recursive: true });
|
|
writeFileSync(resolve(dshHome, 'storages/workspace.json'), `${JSON.stringify({
|
|
unit: { name: 'workspace', version: 2 },
|
|
global: { initialized: true, workspaceIds: [workspaceId], archivedSessionIds: [] },
|
|
tables: {
|
|
workspaces: {
|
|
[workspaceId]: { path: root, title: 'WorkDSH Probe', sessionIds: [], createdAt: now, updatedAt: now },
|
|
},
|
|
},
|
|
}, null, 2)}\n`);
|
|
const dsh = resolve(root, 'node_modules/@deepseek-ai/dsh/lib/bin.js');
|
|
let server;
|
|
let output = '';
|
|
let activeAgentId;
|
|
|
|
async function until(predicate, label) {
|
|
const deadline = Date.now() + 20_000;
|
|
while (!predicate()) {
|
|
if (server?.exitCode !== null && server?.exitCode !== undefined) throw new Error(`Host exited before ${label}: ${output}`);
|
|
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label}: ${output}`);
|
|
await new Promise(resolveWait => setTimeout(resolveWait, 100));
|
|
}
|
|
}
|
|
|
|
async function stopServer() {
|
|
if (!server || server.exitCode !== null || server.signalCode !== null) return;
|
|
const closed = new Promise(resolveClose => server.once('close', resolveClose));
|
|
server.kill('SIGTERM');
|
|
const timer = setTimeout(() => server.kill('SIGKILL'), 3_000);
|
|
await closed;
|
|
clearTimeout(timer);
|
|
}
|
|
|
|
async function authenticate(address) {
|
|
const loginUrl = output.match(/http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+/)?.[0];
|
|
assert.ok(loginUrl, 'Host prints a local bootstrap URL');
|
|
const login = await fetch(loginUrl, { redirect: 'manual', signal: AbortSignal.timeout(5_000) });
|
|
const cookie = login.headers.getSetCookie().map(value => value.split(';')[0]).join('; ');
|
|
assert.ok(cookie, 'bootstrap sets an authenticated session cookie');
|
|
return cookie;
|
|
}
|
|
|
|
async function startHost(initialize = false) {
|
|
output = '';
|
|
const args = ['--profile', 'preset-probe'];
|
|
if (initialize) args.push('--from-default-profile', 'web');
|
|
args.push('--no-open', '--host', '127.0.0.1', '--port', '0');
|
|
server = spawn(process.execPath, [dsh, ...args], {
|
|
cwd: root,
|
|
env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
server.stdout.on('data', chunk => { output += chunk; });
|
|
server.stderr.on('data', chunk => { output += chunk; });
|
|
await until(() => /http:\/\/127\.0\.0\.1:\d+/.test(output), 'Web address');
|
|
const address = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0];
|
|
return { address, cookie: await authenticate(address) };
|
|
}
|
|
|
|
async function listSkills(address, cookie, sessionId) {
|
|
const response = await fetch(`${address}/api/skills/list`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', cookie },
|
|
body: JSON.stringify({
|
|
type: 'client-request',
|
|
rpcId: randomUUID(),
|
|
method: 'skills/list',
|
|
payload: { args: { request: { sessionId } } },
|
|
}),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
assert.ok(response.ok, 'Host accepted the official skills/list request');
|
|
const body = await response.json();
|
|
assert.equal(body.result?.ok, true, 'catalog errors must not become an empty successful directory');
|
|
assert.ok(Array.isArray(body.result.value?.skills), 'catalog contains an explicit skills array');
|
|
return body;
|
|
}
|
|
|
|
const rows = await discoverPresets(presetRoots, import.meta.url);
|
|
const cordis = rows.find(row => row.id === 'cordis');
|
|
const minimal = rows.find(row => row.id === 'minimal');
|
|
assert.ok(cordis && minimal, 'published package supplies cordis and minimal presets');
|
|
await copyComposition(presetRoots, cordis, 'workdsh-skills', 'WorkDSH Skills');
|
|
await copyComposition(presetRoots, minimal, 'workdsh-minimal', 'WorkDSH Minimal');
|
|
|
|
try {
|
|
const { address, cookie } = await startHost(true);
|
|
const browser = await chromium.launch({ headless: true });
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
|
const page = await context.newPage();
|
|
try {
|
|
page.on('request', request => {
|
|
const pathname = new URL(request.url()).pathname;
|
|
if (/agentPreset|agent-preset|skills/i.test(pathname)) {
|
|
const detail = ['/api/agentPresets/select', '/api/skills/list'].includes(pathname) ? ` ${request.postData()}` : '';
|
|
console.log(`TRACE: ${request.method()} ${pathname}${detail}`);
|
|
if (pathname === '/api/agentPresets/select') {
|
|
activeAgentId = JSON.parse(request.postData()).payload.args.agentId;
|
|
}
|
|
}
|
|
});
|
|
page.on('response', async response => {
|
|
if (new URL(response.url()).pathname === '/api/skills/list') {
|
|
const body = await response.json();
|
|
const names = body?.result?.value?.skills?.map(skill => skill.name) ?? [];
|
|
console.log(`TRACE: skills/list ${response.status()} ${names.includes('cordis-plugin-development') ? 'with-cordis' : 'without-cordis'} (${names.length})`);
|
|
}
|
|
});
|
|
await page.context().addCookies(cookie.split('; ').map(pair => {
|
|
const at = pair.indexOf('=');
|
|
return { name: pair.slice(0, at), value: pair.slice(at + 1), url: address, httpOnly: true, sameSite: 'Strict' };
|
|
}));
|
|
await page.goto(address, { waitUntil: 'domcontentloaded' });
|
|
await page.getByRole('button', { name: 'Continue', exact: true }).waitFor({ state: 'visible', timeout: 10_000 })
|
|
.then(() => page.getByRole('button', { name: 'Continue', exact: true }).click()).catch(() => {});
|
|
await page.getByRole('button', { name: 'Configure later', exact: true }).waitFor({ state: 'visible', timeout: 5_000 })
|
|
.then(() => page.getByRole('button', { name: 'Configure later', exact: true }).click()).catch(() => {});
|
|
|
|
await expect(page.getByRole('button', { name: /Standard mode|标准模式/i })).toBeVisible({ timeout: 20_000 });
|
|
await page.getByRole('button', { name: /Standard mode|标准模式/i }).click();
|
|
await expect(page.getByText('WorkDSH Skills', { exact: true })).toBeVisible();
|
|
await expect(page.getByText('WorkDSH Minimal', { exact: true })).toBeVisible();
|
|
await page.keyboard.press('Escape');
|
|
|
|
await page.getByRole('button', { name: /Choose workspace|选择工作区/i }).click();
|
|
await page.getByText('WorkDSH Probe', { exact: true }).last().click();
|
|
|
|
const composer = page.locator('textarea, [contenteditable="true"]').last();
|
|
await expect(composer).toBeVisible();
|
|
await page.getByRole('button', { name: /Standard mode|标准模式/i }).click();
|
|
const initialSkillsSelect = page.waitForResponse(response => new URL(response.url()).pathname === '/api/agentPresets/select');
|
|
await page.getByText('WorkDSH Skills', { exact: true }).click();
|
|
assert.ok((await initialSkillsSelect).ok(), 'initial blank-session preset switch succeeded');
|
|
await expect(page.getByRole('button', { name: /WorkDSH Skills/ })).toBeVisible();
|
|
await page.waitForTimeout(250);
|
|
await composer.fill('/');
|
|
await expect(page.getByText('cordis-plugin-development', { exact: true })).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.getByText('editing-cordis-compositions', { exact: true })).toBeVisible();
|
|
await composer.fill('');
|
|
await page.keyboard.press('Escape');
|
|
await expect(page.getByText('cordis-plugin-development', { exact: true })).not.toBeVisible();
|
|
await page.getByRole('button', { name: /WorkDSH Skills/ }).click();
|
|
const selectMinimal = page.waitForResponse(response => new URL(response.url()).pathname === '/api/agentPresets/select');
|
|
await page.getByText('WorkDSH Minimal', { exact: true }).click();
|
|
assert.ok((await selectMinimal).ok(), 'blank-session preset switch succeeded');
|
|
await expect(page.getByRole('button', { name: /WorkDSH Minimal/ })).toBeVisible();
|
|
await page.waitForTimeout(250);
|
|
const minimalList = page.waitForResponse(response => new URL(response.url()).pathname === '/api/skills/list');
|
|
await composer.fill('/');
|
|
const minimalBody = await (await minimalList).json();
|
|
const minimalNames = minimalBody?.result?.value?.skills?.map(skill => skill.name) ?? [];
|
|
assert.ok(!minimalNames.includes('cordis-plugin-development'));
|
|
assert.ok(!minimalNames.includes('editing-cordis-compositions'));
|
|
await expect(page.getByText('cordis-plugin-development', { exact: true })).not.toBeVisible();
|
|
await expect(page.getByText('editing-cordis-compositions', { exact: true })).not.toBeVisible();
|
|
await composer.fill('');
|
|
await page.keyboard.press('Escape');
|
|
await page.getByRole('button', { name: /WorkDSH Minimal/ }).click();
|
|
const selectSkills = page.waitForResponse(response => new URL(response.url()).pathname === '/api/agentPresets/select');
|
|
await page.getByText('WorkDSH Skills', { exact: true }).click();
|
|
assert.ok((await selectSkills).ok(), 'second blank-session preset switch succeeded');
|
|
await page.waitForTimeout(250);
|
|
await composer.fill('/');
|
|
await expect(page.getByText('cordis-plugin-development', { exact: true })).toBeVisible({ timeout: 10_000 });
|
|
await page.screenshot({ path: resolve(root, '.artifacts/preset-skills.png'), fullPage: true });
|
|
console.log('PASS: native picker switched one blank session between presets and refreshed the official skill directory');
|
|
|
|
await composer.fill('P0-03 nonblank lock probe');
|
|
const promptResponse = page.waitForResponse(response => /prompt/i.test(new URL(response.url()).pathname));
|
|
await composer.press('Enter');
|
|
await promptResponse;
|
|
await expect(page.getByText('P0-03 nonblank lock probe', { exact: true }).last()).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.getByText(/MISSING_CREDENTIAL/).last()).toBeVisible({ timeout: 10_000 });
|
|
assert.ok(activeAgentId, 'session agent id was observed through the public select request');
|
|
const locked = await page.evaluate(async ({ agentId }) => {
|
|
const response = await fetch('/api/agentPresets/select', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
type: 'client-request',
|
|
rpcId: crypto.randomUUID(),
|
|
method: 'agentPresets/select',
|
|
payload: { args: { agentId, agentPreset: 'workdsh-minimal' } },
|
|
}),
|
|
});
|
|
return response.json();
|
|
}, { agentId: activeAgentId });
|
|
assert.equal(locked.result?.ok, false);
|
|
assert.equal(locked.result?.error?.code, 'agent-preset/locked');
|
|
console.log('PASS: the official Host rejected a preset change after the Session became nonblank');
|
|
|
|
// A remains live while a second browser page creates B through the native UI.
|
|
const firstAgentId = activeAgentId;
|
|
const secondPage = await page.context().newPage();
|
|
try {
|
|
await secondPage.goto(address, { waitUntil: 'domcontentloaded' });
|
|
await secondPage.getByRole('button', { name: 'Continue', exact: true }).waitFor({ state: 'visible', timeout: 10_000 })
|
|
.then(() => secondPage.getByRole('button', { name: 'Continue', exact: true }).click()).catch(() => {});
|
|
await secondPage.getByRole('button', { name: 'Configure later', exact: true }).waitFor({ state: 'visible', timeout: 5_000 })
|
|
.then(() => secondPage.getByRole('button', { name: 'Configure later', exact: true }).click()).catch(() => {});
|
|
await secondPage.getByRole('button', { name: /New session|新会话/i }).first().click();
|
|
await secondPage.getByRole('button', { name: 'Configure later', exact: true }).waitFor({ state: 'visible', timeout: 5_000 })
|
|
.then(() => secondPage.getByRole('button', { name: 'Configure later', exact: true }).click()).catch(() => {});
|
|
await secondPage.getByRole('button', { name: /Choose workspace|选择工作区/i }).click();
|
|
await secondPage.getByText('WorkDSH Probe', { exact: true }).last().click();
|
|
await secondPage.getByRole('button', { name: /Standard mode|标准模式|WorkDSH Skills/i }).click();
|
|
const secondSelect = secondPage.waitForResponse(response => new URL(response.url()).pathname === '/api/agentPresets/select');
|
|
await secondPage.getByText('WorkDSH Minimal', { exact: true }).click();
|
|
const selection = await secondSelect;
|
|
assert.ok(selection.ok());
|
|
const secondAgentId = JSON.parse(selection.request().postData()).payload.args.agentId;
|
|
assert.notEqual(secondAgentId, firstAgentId);
|
|
const [firstCatalog, secondCatalog] = await Promise.all([
|
|
listSkills(address, cookie, firstAgentId), listSkills(address, cookie, secondAgentId),
|
|
]);
|
|
assert.ok(firstCatalog.result.value.skills.some(skill => skill.name === 'cordis-plugin-development'));
|
|
assert.deepEqual(secondCatalog.result.value.skills, []);
|
|
await secondPage.getByRole('button', { name: /WorkDSH Minimal/ }).click();
|
|
const switchSecond = secondPage.waitForResponse(response => new URL(response.url()).pathname === '/api/agentPresets/select');
|
|
await secondPage.getByText('WorkDSH Skills', { exact: true }).click();
|
|
assert.ok((await switchSecond).ok());
|
|
const [firstAfter, secondAfter] = await Promise.all([
|
|
listSkills(address, cookie, firstAgentId), listSkills(address, cookie, secondAgentId),
|
|
]);
|
|
assert.deepEqual(firstAfter.result.value.skills, firstCatalog.result.value.skills);
|
|
assert.ok(secondAfter.result.value.skills.some(skill => skill.name === 'cordis-plugin-development'));
|
|
console.log('PASS: two live Sessions have independent catalogs; switching B leaves nonblank A unchanged');
|
|
} catch (error) {
|
|
await secondPage.screenshot({ path: resolve(root, '.artifacts/preset-second-session-failure.png'), fullPage: true }).catch(() => {});
|
|
console.error((await secondPage.locator('body').innerText()).slice(0, 3_000));
|
|
throw error;
|
|
} finally {
|
|
await secondPage.close();
|
|
}
|
|
} catch (error) {
|
|
await page.screenshot({ path: resolve(root, '.artifacts/preset-skills-failure.png'), fullPage: true }).catch(() => {});
|
|
console.error((await page.locator('body').innerText()).slice(0, 5_000));
|
|
throw error;
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
await stopServer();
|
|
server = undefined;
|
|
const { address: restartAddress, cookie: restartCookie } = await startHost();
|
|
const restartBody = await listSkills(restartAddress, restartCookie, activeAgentId);
|
|
const restartNames = restartBody?.result?.value?.skills?.map(skill => skill.name) ?? [];
|
|
assert.ok(restartNames.includes('cordis-plugin-development'));
|
|
assert.ok(restartNames.includes('editing-cordis-compositions'));
|
|
console.log(`PASS: restarted Host reconstructed the Session preset and returned ${restartNames.length} official skills`);
|
|
|
|
await stopServer();
|
|
server = undefined;
|
|
copyFileSync(
|
|
resolve(userRoot, 'workdsh-minimal/agent.cordis.yml'),
|
|
resolve(userRoot, 'workdsh-skills/agent.cordis.yml'),
|
|
);
|
|
const { address: mutatedAddress, cookie: mutatedCookie } = await startHost();
|
|
const mutatedBody = await listSkills(mutatedAddress, mutatedCookie, activeAgentId);
|
|
const mutatedNames = mutatedBody?.result?.value?.skills?.map(skill => skill.name) ?? [];
|
|
assert.equal(mutatedNames.length, 0);
|
|
console.log('PASS: after a preset file mutation and Host restart, the historical Session resolved the new composition for the same preset id');
|
|
|
|
await stopServer();
|
|
server = undefined;
|
|
copyFileSync(
|
|
resolve(SHIPPED_PRESET_ROOT, 'cordis/agent.cordis.yml'),
|
|
resolve(userRoot, 'workdsh-skills/agent.cordis.yml'),
|
|
);
|
|
const { address: restoredAddress, cookie: restoredCookie } = await startHost();
|
|
const restoredBody = await listSkills(restoredAddress, restoredCookie, activeAgentId);
|
|
const restoredNames = restoredBody?.result?.value?.skills?.map(skill => skill.name) ?? [];
|
|
assert.ok(restoredNames.includes('cordis-plugin-development'));
|
|
assert.ok(restoredNames.includes('editing-cordis-compositions'));
|
|
|
|
await stopServer();
|
|
server = undefined;
|
|
rmSync(resolve(userRoot, 'workdsh-skills'), { recursive: true, force: true });
|
|
const { address: deletedAddress, cookie: deletedCookie } = await startHost();
|
|
const deletedBody = await listSkills(deletedAddress, deletedCookie, activeAgentId);
|
|
assert.equal(deletedBody?.result?.ok, true);
|
|
assert.deepEqual(deletedBody?.result?.value?.skills, []);
|
|
console.log('OBSERVED: after preset deletion and Host restart, skills/list silently returned an empty directory for the historical Session');
|
|
} finally {
|
|
await stopServer();
|
|
}
|