Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/android/src/mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ export class AndroidMidsceneTools extends BaseMidsceneTools<AndroidAgent> {
debug('Creating Android agent with deviceId:', deviceId || 'auto-detect');
const agent = await agentFromAdbDevice(deviceId, {
autoDismissKeyboard: false,
reportFileName: this.getPersistedReportFileName(),
});
this.agent = agent;
this.persistAgentReportFileName(this.agent);
return agent;
}

Expand Down
5 changes: 4 additions & 1 deletion packages/computer/src/mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,12 @@ export class ComputerMidsceneTools extends BaseMidsceneTools<ComputerAgent> {
...(headless !== undefined ? { headless } : {}),
};
const agent = await agentFromComputer(
Object.keys(opts).length > 0 ? opts : undefined,
Object.keys(opts).length > 0
? { ...opts, reportFileName: this.getPersistedReportFileName() }
: { reportFileName: this.getPersistedReportFileName() },
);
this.agent = agent;
this.persistAgentReportFileName(this.agent);
return agent;
}

Expand Down
27 changes: 26 additions & 1 deletion packages/core/src/report-generator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { getMidsceneRunSubDir } from '@midscene/shared/common';
import {
Expand Down Expand Up @@ -119,6 +119,7 @@ export class ReportGenerator implements IReportGenerator {
},
alsoWriteFileCopy: this.shouldPersistExecutionDump,
});
this.hydrateStateFromExistingReport();
this.printReportPath('will be generated at');
}

Expand Down Expand Up @@ -241,6 +242,30 @@ export class ReportGenerator implements IReportGenerator {
}
}

private hydrateStateFromExistingReport(): void {
if (!existsSync(this.reportPath)) {
return;
}

// Reuse existing report file and append new updates instead of rewriting.
this.initialized = true;

if (!this.shouldPersistExecutionDump) {
return;
}

const reportDir = dirname(this.reportPath);
const existingExecutionIndices = readdirSync(reportDir)
.map((name) => /^(\d+)\.execution\.json$/.exec(name)?.[1])
.filter((index): index is string => Boolean(index))
.map((index) => Number.parseInt(index, 10))
.filter((index) => Number.isFinite(index));

if (existingExecutionIndices.length > 0) {
this.executionLogIndex = Math.max(...existingExecutionIndices);
}
}

private getDumpScriptAttributes(): Record<string, string> {
return {
'data-group-id': this.reportStreamId,
Expand Down
82 changes: 82 additions & 0 deletions packages/core/tests/unit-test/report-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,48 @@ describe('ReportGenerator — append-only model', () => {
expect(countGroupedDumpScripts(html)).toBe(3);
});

it('should append to existing report file when a new generator uses the same path', async () => {
const reportPath = join(tmpDir, 'append-existing-report.html');
const firstGenerator = new ReportGenerator({
reportPath,
screenshotMode: 'inline',
persistExecutionDump: true,
autoPrint: false,
});

const firstScreenshot = ScreenshotItem.create(
fakeBase64(100),
Date.now(),
);
firstGenerator.onExecutionUpdate(
createExecution([firstScreenshot], 'first-execution', 'exec-1'),
defaultReportMeta,
);
await firstGenerator.finalize();

const secondGenerator = new ReportGenerator({
reportPath,
screenshotMode: 'inline',
persistExecutionDump: true,
autoPrint: false,
});
const secondScreenshot = ScreenshotItem.create(
fakeBase64(120),
Date.now(),
);
secondGenerator.onExecutionUpdate(
createExecution([secondScreenshot], 'second-execution', 'exec-2'),
defaultReportMeta,
);
await secondGenerator.finalize();

const html = readFileSync(reportPath, 'utf-8');
// each finalize() re-writes last execution once, so total dump tags = 4
expect(countGroupedDumpScripts(html)).toBe(4);
expect(html).toContain(firstScreenshot.id);
expect(html).toContain(secondScreenshot.id);
});

it('should append and override report attributes across updates', async () => {
const reportPath = join(tmpDir, 'attribute-merge-inline.html');
const generator = new ReportGenerator({
Expand Down Expand Up @@ -282,6 +324,46 @@ describe('ReportGenerator — append-only model', () => {
);
});

it('should continue execution dump index when appending with the same report path', async () => {
const reportPath = join(tmpDir, 'append-existing-report-with-json.html');
const firstGenerator = new ReportGenerator({
reportPath,
screenshotMode: 'inline',
persistExecutionDump: true,
autoPrint: false,
});
firstGenerator.onExecutionUpdate(
createExecution(
[ScreenshotItem.create(fakeBase64(90), Date.now())],
'first-execution',
'first-id',
),
defaultReportMeta,
);
await firstGenerator.finalize();

const secondGenerator = new ReportGenerator({
reportPath,
screenshotMode: 'inline',
persistExecutionDump: true,
autoPrint: false,
});
secondGenerator.onExecutionUpdate(
createExecution(
[ScreenshotItem.create(fakeBase64(110), Date.now())],
'second-execution',
'second-id',
),
defaultReportMeta,
);
await secondGenerator.finalize();

const jsonFiles = readdirSync(tmpDir)
.filter((name) => /^\d+\.execution\.json$/.test(name))
.sort();
expect(jsonFiles).toEqual(['1.execution.json', '2.execution.json']);
});

it('should persist execution dump files with pretty-printed JSON', async () => {
const reportPath = join(tmpDir, 'pretty-execution-json.html');
const generator = new ReportGenerator({
Expand Down
2 changes: 2 additions & 0 deletions packages/ios/src/mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ export class IOSMidsceneTools extends BaseMidsceneTools<IOSAgent> {
debug('Creating iOS agent with WebDriverAgent');
this.agent = await agentFromWebDriverAgent({
autoDismissKeyboard: false,
reportFileName: this.getPersistedReportFileName(),
});
this.persistAgentReportFileName(this.agent);
return this.agent;
}

Expand Down
17 changes: 17 additions & 0 deletions packages/shared/src/mcp/base-tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { parseBase64 } from '@midscene/shared/img';
import { getDebug } from '@midscene/shared/logger';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import {
persistReportFileName,
readPersistedReportFileName,
} from './report-file-name';
import {
generateCommonTools,
generateToolsFromActionSpace,
Expand Down Expand Up @@ -182,4 +186,17 @@ export abstract class BaseMidsceneTools<TAgent extends BaseAgent = BaseAgent>
return this.buildTextResult(`Disconnected from ${platformName}`);
};
}

protected getPersistedReportFileName(): string | undefined {
return readPersistedReportFileName();
}

protected persistAgentReportFileName(agent: TAgent): void {
if (!agent.reportFileName) {
throw new Error(
'agent reportFileName is required when persisting report state',
);
}
persistReportFileName(agent.reportFileName);
}
}
1 change: 1 addition & 0 deletions packages/shared/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './types';
export * from './inject-report-html-plugin';
export * from './launcher-helper';
export * from './chrome-path';
export * from './report-file-name';
32 changes: 32 additions & 0 deletions packages/shared/src/mcp/report-file-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { getMidsceneRunBaseDir } from '../common';

const REPORT_NAME_STATE_FILE = 'current-report-name';

function getReportNameStateFilePath() {
return path.join(getMidsceneRunBaseDir(), REPORT_NAME_STATE_FILE);
Comment on lines +5 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Isolate persisted report name per MCP session

Persisting the active report to a single global file (midscene_run/current-report-name) makes every new MCP connection in the same working directory reuse the same report indefinitely, because this state is never scoped or rotated. In practice, unrelated runs (or a second MCP client started later) will append into prior sessions, merging independent executions and corrupting report boundaries/results.

Useful? React with 👍 / 👎.

}

export function readPersistedReportFileName(): string | undefined {
const filePath = getReportNameStateFilePath();
if (!existsSync(filePath)) {
return undefined;
}

const content = readFileSync(filePath, 'utf-8').trim();
if (!content) {
return undefined;
}

return content;
}

export function persistReportFileName(reportFileName: string): void {
if (!reportFileName.trim()) {
throw new Error('reportFileName must not be empty');
}

const filePath = getReportNameStateFilePath();
writeFileSync(filePath, `${reportFileName}\n`, 'utf-8');
}
1 change: 1 addition & 0 deletions packages/shared/src/mcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface ActionSpaceItem {
*/
export interface BaseAgent {
getActionSpace(): Promise<ActionSpaceItem[]>;
reportFileName?: string;
destroy?(): Promise<void>;
page?: {
screenshotBase64(): Promise<string>;
Expand Down
33 changes: 33 additions & 0 deletions packages/shared/tests/unit-test/report-file-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { getMidsceneRunBaseDir } from '../../src/common';
import {
persistReportFileName,
readPersistedReportFileName,
} from '../../src/mcp/report-file-name';

describe('report file name state', () => {
const stateFilePath = join(getMidsceneRunBaseDir(), 'current-report-name');

afterEach(() => {
if (existsSync(stateFilePath)) {
rmSync(stateFilePath);
}
});

it('should persist and read report file name', () => {
persistReportFileName('report-a');
expect(readPersistedReportFileName()).toBe('report-a');
});

it('should return undefined when no persisted report file name', () => {
expect(readPersistedReportFileName()).toBeUndefined();
});

it('should throw when persisting an empty report file name', () => {
expect(() => persistReportFileName(' ')).toThrow(
/reportFileName must not be empty/,
);
});
});
5 changes: 4 additions & 1 deletion packages/web-integration/src/mcp-tools-cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,10 @@ export class WebCdpMidsceneTools extends BaseMidsceneTools<PuppeteerAgent> {
await page.bringToFront();
}

this.agent = new PuppeteerAgent(page as unknown as PuppeteerPage);
this.agent = new PuppeteerAgent(page as unknown as PuppeteerPage, {
reportFileName: this.getPersistedReportFileName(),
});
this.persistAgentReportFileName(this.agent);
return this.agent;
}

Expand Down
5 changes: 4 additions & 1 deletion packages/web-integration/src/mcp-tools-puppeteer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ export class WebPuppeteerMidsceneTools extends BaseMidsceneTools<PuppeteerAgent>
}
}

this.agent = new PuppeteerAgent(page as unknown as PuppeteerPage);
this.agent = new PuppeteerAgent(page as unknown as PuppeteerPage, {
reportFileName: this.getPersistedReportFileName(),
});
this.persistAgentReportFileName(this.agent);
return this.agent;
}

Expand Down
6 changes: 5 additions & 1 deletion packages/web-integration/src/mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,18 @@ export class WebMidsceneTools extends BaseMidsceneTools<AgentOverChromeBridge> {
private async initBridgeModeAgent(
url?: string,
): Promise<AgentOverChromeBridge> {
const agent = new AgentOverChromeBridge({ closeConflictServer: true });
const agent = new AgentOverChromeBridge({
closeConflictServer: true,
reportFileName: this.getPersistedReportFileName(),
});

if (!url) {
await agent.connectCurrentTab();
} else {
await agent.connectNewTabWithUrl(url);
}

this.persistAgentReportFileName(agent);
return agent;
}

Expand Down
Loading