mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
feat(browser): 增加 artifact 持久化与导入导出能力
新增 recording、replay、orchestration 的本地 JSON artifact 导出、导入、列出与删除能力。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d0e6087126
commit
2f167d8445
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user