mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(record): bug fix
This commit is contained in:
@@ -23,7 +23,7 @@ describe("RecordingTabCoordinator", () => {
|
||||
|
||||
expect(tabs.isLatest(second)).toBe(false);
|
||||
expect(tabs.isLatest(third)).toBe(true);
|
||||
expect(tabs.commit(6)).toBe(4);
|
||||
tabs.commit(6);
|
||||
expect(tabs.currentTabId).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,15 @@ describe("trace reducer v3", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not export a tab transition without both observation endpoints", () => {
|
||||
const sourceOnly: RecordingDraftStep = { op: "switch_tab", preStateId: "s1" };
|
||||
const targetOnly: RecordingDraftStep = { op: "switch_tab", postStateId: "s2" };
|
||||
|
||||
expect(
|
||||
reduceTraceStepsV3([sourceOnly, targetOnly], { includeTabSwitches: true }).steps,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("collapses redirect hops while retaining draft-to-step identity", () => {
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{ op: "navigate", url: "https://example.com/start", preStateId: "s1", postStateId: "s2" },
|
||||
|
||||
@@ -165,12 +165,8 @@ export class RecordingObservationRuntime {
|
||||
await context.settle.flush();
|
||||
}
|
||||
|
||||
async flushRedirects(tabId?: number): Promise<void> {
|
||||
if (tabId !== undefined) {
|
||||
await this.#contexts.get(tabId)?.settle.flushRedirects();
|
||||
return;
|
||||
}
|
||||
for (const context of this.#contexts.values()) await context.settle.flushRedirects();
|
||||
async flushRedirects(tabId: number): Promise<void> {
|
||||
await this.#contexts.get(tabId)?.settle.flushRedirects();
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
|
||||
@@ -40,10 +40,8 @@ export class RecordingTabCoordinator {
|
||||
);
|
||||
}
|
||||
|
||||
commit(tabId: number): number {
|
||||
const previousTabId = this.#currentTabId;
|
||||
commit(tabId: number): void {
|
||||
this.#currentTabId = tabId;
|
||||
return previousTabId;
|
||||
}
|
||||
|
||||
navigation(tabId: number, fallbackUrl?: string): RecordingNavigationCursor {
|
||||
|
||||
@@ -69,6 +69,15 @@ function reduceDraft(
|
||||
options: ReduceDraftOptions,
|
||||
): StepV3 | null {
|
||||
if (!shouldIncludeDraft(draft)) return null;
|
||||
if (draft.op === "switch_tab") {
|
||||
if (!options.includeTabSwitches || !draft.preStateId || !draft.postStateId) return null;
|
||||
return {
|
||||
op: "switch_tab",
|
||||
id,
|
||||
state: draft.preStateId,
|
||||
result: { state: draft.postStateId },
|
||||
};
|
||||
}
|
||||
const state = draft.preStateId ?? draft.postStateId;
|
||||
const resultState = draft.postStateId ?? draft.preStateId;
|
||||
if (!state || !resultState) return null;
|
||||
@@ -77,8 +86,6 @@ function reduceDraft(
|
||||
switch (draft.op) {
|
||||
case "navigate":
|
||||
return { op: "navigate", ...common, to: draft.url, cause: navigationCause(draft) };
|
||||
case "switch_tab":
|
||||
return options.includeTabSwitches ? { op: "switch_tab", ...common } : null;
|
||||
case "click":
|
||||
return {
|
||||
op: "click",
|
||||
|
||||
@@ -1376,6 +1376,62 @@ describe("recorded user steps reach the exported trace", () => {
|
||||
expect(stateUrl(switches[0]!.result.state)).toBe("https://example.com/tab-6");
|
||||
});
|
||||
|
||||
it("does not retry an in-flight tab rearm after recording stops", async () => {
|
||||
const chromeApi = installChrome();
|
||||
const manager = fakeManager();
|
||||
const tabsApi = makeMultiTabsApi([
|
||||
{
|
||||
id: TAB_ID,
|
||||
windowId: AGENT_WINDOW_ID,
|
||||
active: true,
|
||||
status: "complete",
|
||||
url: START_URL,
|
||||
} as chrome.tabs.Tab,
|
||||
{
|
||||
id: 5,
|
||||
windowId: AGENT_WINDOW_ID,
|
||||
active: false,
|
||||
status: "complete",
|
||||
url: "https://example.com/second",
|
||||
} as chrome.tabs.Tab,
|
||||
]);
|
||||
let failRearm!: () => void;
|
||||
const rearmGate = new Promise<never>((_resolve, reject) => {
|
||||
failRearm = () => reject(new Error("rearm failed after stop"));
|
||||
});
|
||||
let announceRearm!: () => void;
|
||||
const rearmStarted = new Promise<void>((resolve) => {
|
||||
announceRearm = resolve;
|
||||
});
|
||||
const sendToTab = vi.fn(async (tabId: number, message: unknown) => {
|
||||
if ((message as { type?: string }).type === RECORD_START && tabId === 5) {
|
||||
announceRearm();
|
||||
await rearmGate;
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() };
|
||||
|
||||
await handleRecordStart(manager, RECORD_START_V3, deps);
|
||||
tabsApi.activate(5);
|
||||
chromeApi.tabsOnActivated.emit({ tabId: 5, windowId: AGENT_WINDOW_ID });
|
||||
await rearmStarted;
|
||||
|
||||
const stoppedPromise = handleRecordStop(manager, { session_id: "abcd" }, deps);
|
||||
await vi.waitFor(() => {
|
||||
expect(sendToTab).toHaveBeenCalledWith(5, expect.objectContaining({ type: RECORD_STOP }));
|
||||
});
|
||||
failRearm();
|
||||
const stopped = await stoppedPromise;
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
|
||||
const startsForSecondTab = sendToTab.mock.calls.filter(
|
||||
([tabId, message]) => tabId === 5 && (message as { type?: string }).type === RECORD_START,
|
||||
);
|
||||
expect(startsForSecondTab).toHaveLength(1);
|
||||
expect(asTraceV3((stopped as RecordStopResult).trace).steps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps pending action settles scoped to their tab during an immediate switch", async () => {
|
||||
const chromeApi = installChrome();
|
||||
const manager = fakeManager();
|
||||
|
||||
@@ -91,6 +91,10 @@ function enqueueRecordingAction(
|
||||
return queued;
|
||||
}
|
||||
|
||||
function isRecordingFinishing(recording: ActiveRecording): boolean {
|
||||
return recording.settled || recording.finishAttempt !== null;
|
||||
}
|
||||
|
||||
const recordings = new Map<string, ActiveRecording>();
|
||||
|
||||
const RECORD_START_RETRIES = 3;
|
||||
@@ -171,9 +175,11 @@ async function sendRecordStartWithAck(
|
||||
tabId: number,
|
||||
msg: RecordStartMessage,
|
||||
sendToTab: RecordDeps["sendToTab"],
|
||||
cancelled?: () => boolean,
|
||||
): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < RECORD_START_RETRIES; attempt += 1) {
|
||||
if (cancelled?.()) throw new Error("record start cancelled");
|
||||
try {
|
||||
const response = await sendToTab(tabId, msg);
|
||||
if (isRecordStartAck(response)) return;
|
||||
@@ -181,6 +187,7 @@ async function sendRecordStartWithAck(
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
if (cancelled?.()) throw new Error("record start cancelled");
|
||||
if (attempt + 1 < RECORD_START_RETRIES) {
|
||||
await sleep(RECORD_START_RETRY_DELAY_MS);
|
||||
}
|
||||
@@ -508,20 +515,29 @@ async function rearmRecording(
|
||||
// content-script counter, and a single stop decrement left the ControlOverlay
|
||||
// stuck with pointer-events:none (page usable, Interrupt dead). RecordOverlay
|
||||
// already hides the control chrome while activeRecord is set.
|
||||
const isFinishing = () => isRecordingFinishing(recording);
|
||||
for (let attempt = 0; attempt < RECORD_REARM_MAX_ATTEMPTS; attempt += 1) {
|
||||
if (isFinishing()) return false;
|
||||
const startMsg: RecordStartMessage = {
|
||||
type: RECORD_START,
|
||||
requestId: recording.requestId,
|
||||
startedAtMs: recording.startedAtMs,
|
||||
};
|
||||
try {
|
||||
await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab);
|
||||
await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab, isFinishing);
|
||||
if (isFinishing()) return false;
|
||||
if (activation) {
|
||||
if (!recording.tabs.isLatest(activation)) return true;
|
||||
await enqueueRecordingAction(recording, () => activateRecordingTab(recording, targetTabId));
|
||||
await enqueueRecordingAction(recording, async () => {
|
||||
if (isFinishing() || !recording.tabs.isLatest(activation)) {
|
||||
return;
|
||||
}
|
||||
await activateRecordingTab(recording, targetTabId);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
if (isFinishing()) return false;
|
||||
if (attempt + 1 < RECORD_REARM_MAX_ATTEMPTS) {
|
||||
await sleep(RECORD_REARM_RETRY_DELAY_MS);
|
||||
}
|
||||
@@ -551,7 +567,7 @@ export function attachRecordTabListener(deps: RecordDeps = getDefaultDeps()): ()
|
||||
const windowId = tab.windowId;
|
||||
if (tabId === undefined || windowId === undefined) return;
|
||||
for (const recording of recordings.values()) {
|
||||
if (recording.settled || recording.agentWindowId !== windowId) continue;
|
||||
if (isRecordingFinishing(recording) || recording.agentWindowId !== windowId) continue;
|
||||
scheduleRearmForTab(tabId, deps);
|
||||
return;
|
||||
}
|
||||
@@ -559,7 +575,8 @@ export function attachRecordTabListener(deps: RecordDeps = getDefaultDeps()): ()
|
||||
|
||||
const onActivated = (activeInfo: chrome.tabs.TabActiveInfo) => {
|
||||
for (const recording of recordings.values()) {
|
||||
if (recording.settled || recording.agentWindowId !== activeInfo.windowId) continue;
|
||||
if (isRecordingFinishing(recording) || recording.agentWindowId !== activeInfo.windowId)
|
||||
continue;
|
||||
const activation = recording.tabs.noteActivation(activeInfo.tabId);
|
||||
scheduleRearmForTab(activeInfo.tabId, deps, activation);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user