feat(browser): 增加 artifact 持久化与导入导出能力

新增 recording、replay、orchestration 的本地 JSON artifact 导出、导入、列出与删除能力。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
walker1211
2026-04-20 20:31:51 +08:00
co-authored by Claude Opus 4.7
parent d0e6087126
commit 2f167d8445
2 changed files with 450 additions and 0 deletions
+217
View File
@@ -72,6 +72,10 @@ const TOOL_CANCEL_REPLAY = 'browser_cancel_replay';
const TOOL_START_ORCHESTRATION = 'browser_start_orchestration';
const TOOL_GET_ORCHESTRATION = 'browser_get_orchestration';
const TOOL_CANCEL_ORCHESTRATION = 'browser_cancel_orchestration';
const TOOL_EXPORT_ARTIFACT = 'browser_export_artifact';
const TOOL_IMPORT_ARTIFACT = 'browser_import_artifact';
const TOOL_LIST_ARTIFACTS = 'browser_list_artifacts';
const TOOL_DELETE_ARTIFACT = 'browser_delete_artifact';
const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot';
const TOOL_WAIT_FOR = 'browser_wait_for';
const TOOL_EVAL = 'browser_eval';
@@ -113,6 +117,10 @@ const TOOL_NAMES = [
TOOL_START_ORCHESTRATION,
TOOL_GET_ORCHESTRATION,
TOOL_CANCEL_ORCHESTRATION,
TOOL_EXPORT_ARTIFACT,
TOOL_IMPORT_ARTIFACT,
TOOL_LIST_ARTIFACTS,
TOOL_DELETE_ARTIFACT,
TOOL_TAKE_SCREENSHOT,
TOOL_WAIT_FOR,
TOOL_EVAL,
@@ -841,6 +849,52 @@ function getTools() {
additionalProperties: false,
},
},
{
name: TOOL_EXPORT_ARTIFACT,
description: 'Export the current recording, replay, or orchestration artifact to a local JSON file.',
inputSchema: {
type: 'object',
properties: {
kind: { type: 'string' },
name: { type: 'string' },
},
required: ['kind', 'name'],
additionalProperties: false,
},
},
{
name: TOOL_IMPORT_ARTIFACT,
description: 'Import a recording, replay, or orchestration artifact from a local JSON file into session-local state.',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string' },
},
required: ['path'],
additionalProperties: false,
},
},
{
name: TOOL_LIST_ARTIFACTS,
description: 'List locally saved Browser MCP artifacts.',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
{
name: TOOL_DELETE_ARTIFACT,
description: 'Delete a locally saved Browser MCP artifact by name.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name'],
additionalProperties: false,
},
},
{
name: TOOL_TAKE_SCREENSHOT,
description:
@@ -2392,6 +2446,52 @@ function formatRecordingSummary(session) {
].join('\n');
}
function getArtifactDir() {
const scoped = Buffer.from(process.cwd()).toString('base64url');
const baseDir = path.join(os.tmpdir(), 'ccs-browser-artifacts', scoped);
if (!fs.existsSync(baseDir)) {
fs.mkdirSync(baseDir, { recursive: true });
}
return baseDir;
}
function getArtifactPath(name) {
return path.join(getArtifactDir(), `${name}.json`);
}
function buildArtifact(kind, name, payload) {
return {
kind,
version: 1,
name,
createdAt: new Date().toISOString(),
payload,
};
}
function readArtifactFile(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error('artifact file not found');
}
const raw = fs.readFileSync(filePath, 'utf8');
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error('invalid artifact payload');
}
if (!parsed || typeof parsed !== 'object') {
throw new Error('invalid artifact payload');
}
if (parsed.version !== 1) {
throw new Error('unsupported artifact version');
}
if (typeof parsed.kind !== 'string' || typeof parsed.name !== 'string' || !('payload' in parsed)) {
throw new Error('invalid artifact payload');
}
return parsed;
}
function createOrchestrationId() {
const orchestrationId = `orc_${String(nextOrchestrationCounter).padStart(4, '0')}`;
nextOrchestrationCounter += 1;
@@ -5265,6 +5365,91 @@ async function handleGetOrchestration() {
return formatOrchestrationSummary(session);
}
function getArtifactPayloadForKind(kind) {
if (kind === 'recording') {
return requireLatestRecording();
}
if (kind === 'replay') {
return requireLatestReplay();
}
if (kind === 'orchestration') {
return requireLatestOrchestration();
}
throw new Error(`unsupported artifact kind: ${kind}`);
}
function resolveArtifactPath(reference) {
const value = requireNonEmptyString(reference, 'path');
if (value.startsWith('artifact:')) {
return getArtifactPath(value.slice('artifact:'.length));
}
return value;
}
function deleteArtifactByName(name) {
const filePath = getArtifactPath(name);
if (!fs.existsSync(filePath)) {
throw new Error('artifact not found');
}
fs.unlinkSync(filePath);
return filePath;
}
async function handleExportArtifact(toolArgs) {
const kind = requireNonEmptyString(toolArgs.kind, 'kind');
const name = requireNonEmptyString(toolArgs.name, 'name');
const filePath = getArtifactPath(name);
if (fs.existsSync(filePath)) {
throw new Error('artifact already exists');
}
const payload = getArtifactPayloadForKind(kind);
const artifact = buildArtifact(kind, name, payload);
fs.writeFileSync(filePath, JSON.stringify(artifact, null, 2), 'utf8');
return `name: ${name}\nkind: ${kind}\npath: ${filePath}\nstatus: exported`;
}
async function handleListArtifacts() {
const dir = getArtifactDir();
const entries = fs
.readdirSync(dir)
.filter((entry) => entry.endsWith('.json'))
.map((entry) => readArtifactFile(path.join(dir, entry)));
if (entries.length === 0) {
return 'artifacts: []';
}
return entries
.map((entry) => [
`name: ${entry.name}`,
`kind: ${entry.kind}`,
`createdAt: ${entry.createdAt}`,
`path: ${getArtifactPath(entry.name)}`,
].join('\n'))
.join('\n---\n');
}
async function handleImportArtifact(toolArgs) {
const filePath = resolveArtifactPath(toolArgs.path);
const artifact = readArtifactFile(filePath);
if (artifact.kind === 'recording') {
latestRecordingSession = artifact.payload;
} else if (artifact.kind === 'replay') {
latestReplaySession = artifact.payload;
} else if (artifact.kind === 'orchestration') {
latestOrchestrationSession = artifact.payload;
} else {
throw new Error('artifact kind mismatch');
}
return `name: ${artifact.name}\nkind: ${artifact.kind}\nstatus: imported`;
}
async function handleDeleteArtifact(toolArgs) {
const name = requireNonEmptyString(toolArgs.name, 'name');
deleteArtifactByName(name);
return `name: ${name}\nstatus: deleted`;
}
async function handleCancelOrchestration() {
if (!activeOrchestrationSession) {
throw new Error('no active orchestration');
@@ -5544,6 +5729,38 @@ async function handleToolCall(message) {
return;
}
if (toolName === TOOL_EXPORT_ARTIFACT) {
const text = await handleExportArtifact(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_IMPORT_ARTIFACT) {
const text = await handleImportArtifact(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_LIST_ARTIFACTS) {
const text = await handleListArtifacts(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_DELETE_ARTIFACT) {
const text = await handleDeleteArtifact(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_TAKE_SCREENSHOT) {
const text = await handleScreenshot(toolArgs);
writeResponse(id, {
@@ -1843,6 +1843,10 @@ describe('ccs-browser MCP server', () => {
'browser_start_orchestration',
'browser_get_orchestration',
'browser_cancel_orchestration',
'browser_export_artifact',
'browser_import_artifact',
'browser_list_artifacts',
'browser_delete_artifact',
'browser_take_screenshot',
'browser_wait_for',
'browser_eval',
@@ -1986,6 +1990,19 @@ describe('ccs-browser MCP server', () => {
const cancelOrchestrationTool = tools.find((tool) => tool.name === 'browser_cancel_orchestration');
expect(cancelOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' });
const exportArtifactTool = tools.find((tool) => tool.name === 'browser_export_artifact');
expect(exportArtifactTool?.inputSchema?.properties?.kind).toMatchObject({ type: 'string' });
expect(exportArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' });
const importArtifactTool = tools.find((tool) => tool.name === 'browser_import_artifact');
expect(importArtifactTool?.inputSchema?.properties?.path).toMatchObject({ type: 'string' });
const listArtifactsTool = tools.find((tool) => tool.name === 'browser_list_artifacts');
expect(listArtifactsTool?.inputSchema).toMatchObject({ type: 'object' });
const deleteArtifactTool = tools.find((tool) => tool.name === 'browser_delete_artifact');
expect(deleteArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' });
const queryTool = tools.find((tool) => tool.name === 'browser_query');
expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({
type: 'array',
@@ -7278,6 +7295,222 @@ describe('ccs-browser MCP server', () => {
expect(text).toContain('failure[0].sequenceStepIndex: 0');
});
it('exports recording and replay artifacts and lists them', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Artifact Page',
currentUrl: 'https://example.com/artifact',
recording: {
events: [
{
kind: 'click',
selector: '#submit',
timestamp: 1710000000000,
},
],
},
click: {
'#submit': {},
},
query: {
'#submit': {
exists: true,
connected: true,
rect: {
x: 20,
y: 30,
width: 100,
height: 40,
top: 30,
right: 120,
bottom: 70,
left: 20,
},
display: 'block',
visibility: 'visible',
opacity: '1',
},
},
},
],
[
{ jsonrpc: '2.0', id: 1601, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
{ jsonrpc: '2.0', id: 1602, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
{
jsonrpc: '2.0',
id: 1603,
method: 'tools/call',
params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'rec-smoke' } },
},
{
jsonrpc: '2.0',
id: 1604,
method: 'tools/call',
params: {
name: 'browser_start_replay',
arguments: {
steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })],
},
},
},
{
jsonrpc: '2.0',
id: 1605,
method: 'tools/call',
params: { name: 'browser_export_artifact', arguments: { kind: 'replay', name: 'replay-smoke' } },
},
{
jsonrpc: '2.0',
id: 1606,
method: 'tools/call',
params: { name: 'browser_list_artifacts', arguments: {} },
},
]
);
expect(getResponseText(responses.find((message) => message.id === 1603))).toContain('status: exported');
expect(getResponseText(responses.find((message) => message.id === 1605))).toContain('status: exported');
const listText = getResponseText(responses.find((message) => message.id === 1606));
expect(listText).toContain('name: rec-smoke');
expect(listText).toContain('name: replay-smoke');
});
it('imports and deletes orchestration artifacts', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Artifact Page',
currentUrl: 'https://example.com/artifact',
click: { '#submit': {} },
wait: {
selectorSnapshots: {
'#submit': [
[
{
exists: true,
rect: {
x: 20,
y: 30,
width: 100,
height: 40,
top: 30,
right: 120,
bottom: 70,
left: 20,
},
display: 'block',
visibility: 'visible',
opacity: '1',
},
],
],
},
},
query: {
'#status': {
exists: true,
connected: true,
innerText: 'ready state',
textContent: 'ready state',
display: 'block',
visibility: 'visible',
opacity: '1',
rect: {
x: 20,
y: 80,
width: 100,
height: 40,
top: 80,
right: 120,
bottom: 120,
left: 20,
},
},
},
},
],
[
{
jsonrpc: '2.0',
id: 1607,
method: 'tools/call',
params: {
name: 'browser_start_orchestration',
arguments: {
blocks: [
createOrchestrationBlock({
type: 'assert_query',
args: {
query: { selector: '#status', fields: ['innerText'] },
assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }],
},
}),
],
},
},
},
{
jsonrpc: '2.0',
id: 1608,
method: 'tools/call',
params: { name: 'browser_export_artifact', arguments: { kind: 'orchestration', name: 'orc-smoke' } },
},
{
jsonrpc: '2.0',
id: 1609,
method: 'tools/call',
params: { name: 'browser_list_artifacts', arguments: {} },
},
{
jsonrpc: '2.0',
id: 1610,
method: 'tools/call',
params: { name: 'browser_import_artifact', arguments: { path: 'artifact:orc-smoke' } },
},
{
jsonrpc: '2.0',
id: 1611,
method: 'tools/call',
params: { name: 'browser_delete_artifact', arguments: { name: 'orc-smoke' } },
},
]
);
expect(getResponseText(responses.find((message) => message.id === 1608))).toContain('status: exported');
expect(getResponseText(responses.find((message) => message.id === 1610))).toContain('status: imported');
expect(getResponseText(responses.find((message) => message.id === 1611))).toContain('status: deleted');
});
it('rejects duplicate export, invalid import payload, and missing artifact delete target', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Artifact Failure Page',
currentUrl: 'https://example.com/artifact-failure',
recording: {
events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000000 }],
},
},
],
[
{ jsonrpc: '2.0', id: 1612, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
{ jsonrpc: '2.0', id: 1613, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
{ jsonrpc: '2.0', id: 1614, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } },
{ jsonrpc: '2.0', id: 1615, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } },
{ jsonrpc: '2.0', id: 1616, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:invalid-json' } } },
{ jsonrpc: '2.0', id: 1617, method: 'tools/call', params: { name: 'browser_delete_artifact', arguments: { name: 'missing-artifact' } } },
]
);
expect(getResponseText(responses.find((message) => message.id === 1615))).toContain('artifact already exists');
expect(getResponseText(responses.find((message) => message.id === 1616))).toContain('artifact file not found');
expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found');
});
it('drags local files onto normal, frame, and shadow dropzones', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-'));
const invoicePath = join(tempDir, 'invoice.pdf');