Skip to content

Commit e65249b

Browse files
committed
test(e2e): simplyfying e2e tests
1 parent d257774 commit e65249b

9 files changed

Lines changed: 148 additions & 241 deletions

File tree

apps/vscode-e2e/src/fixtures/subtasks.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { toolResultContains } from "./tool-result"
66
const SUBTASK_PARENT_MARKER = "SUBTASK_PARENT_CANCELLATION_SMOKE"
77
const SUBTASK_CHILD_MARKER = "SUBTASK_CHILD_CALCULATOR_SMOKE"
88

9-
export const SUBTASK_CHILD_PROMPT = `${SUBTASK_CHILD_MARKER}: Ask the user exactly this follow-up question: What is the square root of 81? After the user answers, complete with only the answer.`
9+
const SUBTASK_CHILD_PROMPT = `${SUBTASK_CHILD_MARKER}: Ask the user exactly this follow-up question: What is the square root of 81? After the user answers, complete with only the answer.`
1010
export const SUBTASK_PARENT_PROMPT = `${SUBTASK_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_CHILD_PROMPT}" Do not answer directly.`
1111
export const SUBTASK_CHILD_FOLLOWUP_ANSWER = "9"
1212

@@ -18,8 +18,11 @@ const requestContains = (req: ChatCompletionRequest, expected: string[]) => {
1818
const completionAfterAnswer = (followupId: string, completionId: string) => ({
1919
match: {
2020
predicate: (req: ChatCompletionRequest) =>
21+
// Preferred: structured tool-result message carries the followup answer.
2122
toolResultContains(req, followupId, [SUBTASK_CHILD_FOLLOWUP_ANSWER]) ||
23+
// Fallback 1: answer present alongside the tool-call ID but not in a role:tool message.
2224
requestContains(req, [followupId, SUBTASK_CHILD_FOLLOWUP_ANSWER]) ||
25+
// Fallback 2: answer arrives as a bare user message after task resume (no tool-call ID context).
2326
requestContains(req, [
2427
SUBTASK_CHILD_MARKER,
2528
`<user_message>\\n${SUBTASK_CHILD_FOLLOWUP_ANSWER}\\n</user_message>`,

apps/vscode-e2e/src/suite/subtasks.test.ts

Lines changed: 80 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,87 @@ import { SUBTASK_CHILD_FOLLOWUP_ANSWER, SUBTASK_PARENT_PROMPT } from "../fixture
99
suite("Roo Code Subtasks", function () {
1010
setDefaultSuiteTimeout(this)
1111

12-
test("Should keep parent paused after subtask cancellation", async () => {
12+
// Race mitigation: skipDelegationRepair prevents removeClineFromStack from
13+
// auto-resuming the parent when the child is cancelled (Race 2).
14+
test("parent stays paused after subtask cancellation", async () => {
1315
const api = globalThis.api
1416
const asks: Record<string, ClineMessage[]> = {}
1517
const messages: Record<string, ClineMessage[]> = {}
16-
const waitForStage = async (label: string, condition: Parameters<typeof waitFor>[0]) => {
17-
try {
18-
await waitFor(condition)
19-
} catch (error) {
20-
const message = error instanceof Error ? error.message : String(error)
21-
throw new Error(`${label}: ${message}`)
18+
19+
const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
20+
if (message.type === "ask") {
21+
asks[taskId] = asks[taskId] || []
22+
asks[taskId].push(message)
23+
}
24+
if (message.type === "say" && message.partial === false) {
25+
messages[taskId] = messages[taskId] || []
26+
messages[taskId].push(message)
2227
}
2328
}
2429

30+
api.on(RooCodeEventName.Message, messageHandler)
31+
32+
try {
33+
const parentTaskId = await api.startNewTask({
34+
configuration: {
35+
mode: "ask",
36+
alwaysAllowModeSwitch: true,
37+
alwaysAllowSubtasks: true,
38+
autoApprovalEnabled: true,
39+
enableCheckpoints: false,
40+
},
41+
text: SUBTASK_PARENT_PROMPT,
42+
})
43+
44+
let spawnedTaskId: string | undefined
45+
await waitFor(() => {
46+
const stack = api.getCurrentTaskStack()
47+
const current = stack[stack.length - 1]
48+
if (current && current !== parentTaskId) {
49+
spawnedTaskId = current
50+
return true
51+
}
52+
return false
53+
})
54+
55+
await waitFor(
56+
() => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false,
57+
)
58+
59+
await api.cancelCurrentTask()
60+
61+
assert.ok(
62+
messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
63+
undefined,
64+
"Parent task should not have resumed after subtask cancellation",
65+
)
66+
67+
await waitFor(() => api.getCurrentTaskStack().at(-1) === spawnedTaskId)
68+
await waitFor(
69+
() => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false,
70+
)
71+
72+
await api.clearCurrentTask()
73+
// The parent task is still in the stack; drain it so it doesn't leak into the next test.
74+
await api.clearCurrentTask()
75+
await waitFor(() => api.getCurrentTaskStack().length === 0)
76+
} finally {
77+
api.off(RooCodeEventName.Message, messageHandler)
78+
}
79+
})
80+
81+
// Race mitigation: runDelegationTransition lock + cancelledDelegationChildIds guard
82+
// ensures cancelTask() wins over a concurrent reopenParentFromDelegation() (Race 3).
83+
test("cancelled child completes in-place and does not reopen parent", async () => {
84+
const api = globalThis.api
85+
const asks: Record<string, ClineMessage[]> = {}
86+
const messages: Record<string, ClineMessage[]> = {}
87+
2588
const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
2689
if (message.type === "ask") {
2790
asks[taskId] = asks[taskId] || []
2891
asks[taskId].push(message)
2992
}
30-
3193
if (message.type === "say" && message.partial === false) {
3294
messages[taskId] = messages[taskId] || []
3395
messages[taskId].push(message)
@@ -64,35 +126,25 @@ suite("Roo Code Subtasks", function () {
64126
})
65127

66128
let spawnedTaskId: string | undefined
67-
await waitForStage("wait for spawned subtask", () => {
68-
const currentTaskStack = api.getCurrentTaskStack()
69-
const currentTaskId = currentTaskStack[currentTaskStack.length - 1]
70-
if (currentTaskId && currentTaskId !== parentTaskId) {
71-
spawnedTaskId = currentTaskId
129+
await waitFor(() => {
130+
const stack = api.getCurrentTaskStack()
131+
const current = stack[stack.length - 1]
132+
if (current && current !== parentTaskId) {
133+
spawnedTaskId = current
72134
return true
73135
}
74136
return false
75137
})
76-
await waitForStage(
77-
"wait for delegated child followup ask",
138+
139+
await waitFor(
78140
() => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false,
79141
)
80-
const cancelledChildTaskId = spawnedTaskId!
81142

143+
const cancelledChildTaskId = spawnedTaskId!
82144
await api.cancelCurrentTask()
83145

84-
assert.ok(
85-
messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
86-
undefined,
87-
"Parent task should not have resumed after subtask cancellation",
88-
)
89-
90-
await waitForStage(
91-
"wait for cancelled child task to remain active",
92-
() => api.getCurrentTaskStack().at(-1) === cancelledChildTaskId,
93-
)
94-
await waitForStage(
95-
"wait for cancelled child resume ask",
146+
await waitFor(() => api.getCurrentTaskStack().at(-1) === cancelledChildTaskId)
147+
await waitFor(
96148
() =>
97149
asks[cancelledChildTaskId]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ??
98150
false,
@@ -126,7 +178,6 @@ suite("Roo Code Subtasks", function () {
126178
cancelledChildTaskId,
127179
"Cancelled child task should remain the active completed task",
128180
)
129-
130181
assert.ok(
131182
messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
132183
undefined,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { ClineProvider } from "../../core/webview/ClineProvider"
2+
3+
/**
4+
* Augments a plain stub object with the instance fields and bound methods that
5+
* ClineProvider methods read from `this` (runDelegationTransition,
6+
* delegationTransitionLocks, cancelledDelegationChildIds), so tests can call
7+
* private methods via `(ClineProvider.prototype as any).method.call(stub, …)`
8+
* without instantiating a real ClineProvider.
9+
*/
10+
export function makeProviderStub<T extends object>(stub: T): T {
11+
const s = stub as any
12+
const proto = ClineProvider.prototype as any
13+
s.delegationTransitionLocks ??= new Map()
14+
s.cancelledDelegationChildIds ??= new Set()
15+
s.runDelegationTransition = proto.runDelegationTransition.bind(s)
16+
return s
17+
}

0 commit comments

Comments
 (0)