Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
79 changes: 76 additions & 3 deletions src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ vi.mock("vscode", () => ({
},
}))

// The no-task failure posts localized copy; the extension i18n loader only
// populates resources outside tests, so the spec pins the English values the
// handler asks for (path is relative to this file: ../../../i18n = src/i18n).
vi.mock("../../../i18n", () => ({
changeLanguage: vi.fn(),
t: (key: string) => {
const values: Record<string, string> = {
"common:errors.message.no_active_task_to_roll_back": "No active task to roll back from",
"common:errors.message.no_active_task_to_restore": "No active task to restore from",
}
return values[key] ?? key
},
}))

// Structural mock: the handler only needs the task identity for these cases.
const mockTask = {} as Task
const postMessageToWebview = vi.fn(async (_message: ExtensionMessage) => undefined)
Expand Down Expand Up @@ -103,7 +117,28 @@ describe("webviewMessageHandler - change card rollback", () => {
cardTs: 1000,
filePath: "src/a.ts",
success: false,
error: "No active task to roll back from.",
error: "No active task to roll back from",
},
})
})

it("posts a correlated failure when the rollback itself throws", async () => {
vi.mocked(rollbackFile).mockRejectedValueOnce(new Error("git restore failed"))

await webviewMessageHandler(provider, {
type: "checkpointRollbackFile",
payload: { cardTs: 1000, checkpointId: "abc123", filePath: "src/a.ts" },
})

// The card must not stay pending: the handler turns the throw into a
// correlated failure result instead of dropping the message.
expect(postMessageToWebview).toHaveBeenCalledWith({
type: "checkpointRollbackResult",
checkpointRollbackResult: {
cardTs: 1000,
filePath: "src/a.ts",
success: false,
error: "Rollback failed: git restore failed",
},
})
})
Expand Down Expand Up @@ -182,7 +217,25 @@ describe("webviewMessageHandler - change card rollback", () => {
checkpointRollbackResult: {
cardTs: 1000,
success: false,
error: "No active task to roll back from.",
error: "No active task to roll back from",
},
})
})

it("posts a correlated failure when the step rollback itself throws", async () => {
vi.mocked(rollbackStep).mockRejectedValueOnce(new Error("journal unreadable"))

await webviewMessageHandler(provider, {
type: "checkpointRollbackStep",
payload: { cardTs: 1000, filePaths: ["src/a.ts"] },
})

expect(postMessageToWebview).toHaveBeenCalledWith({
type: "checkpointRollbackResult",
checkpointRollbackResult: {
cardTs: 1000,
success: false,
error: "Rollback failed: journal unreadable",
},
})
})
Expand Down Expand Up @@ -293,7 +346,27 @@ describe("webviewMessageHandler - change card rollback", () => {
kind: "restore-latest",
filePath: "src/a.ts",
success: false,
error: "No active task to restore from.",
error: "No active task to restore from",
},
})
})

it("posts a correlated failure when the restore itself throws", async () => {
vi.mocked(restoreLatestFile).mockRejectedValueOnce(new Error("git checkout failed"))

await webviewMessageHandler(provider, {
type: "checkpointRestoreLatestFile",
payload: { cardTs: 1000, filePath: "src/a.ts" },
})

expect(postMessageToWebview).toHaveBeenCalledWith({
type: "checkpointRollbackResult",
checkpointRollbackResult: {
cardTs: 1000,
kind: "restore-latest",
filePath: "src/a.ts",
success: false,
error: "Restore failed: git checkout failed",
},
})
})
Expand Down
119 changes: 80 additions & 39 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1614,17 +1614,32 @@ export const webviewMessageHandler = async (
// editor integrations (DiffViewProvider) into the import graph. Loading it
// only when a rollback is requested keeps specs that mock `vscode` minimally
// from executing editor module-scope code at import time.
const { rollbackFile } = await import("../checkpoints/rollback")
const outcome = await rollbackFile(task, result.data.checkpointId, result.data.filePath)
await provider.postMessageToWebview({
type: "checkpointRollbackResult",
checkpointRollbackResult: {
cardTs: result.data.cardTs,
filePath: outcome.filePath,
success: outcome.success,
...(outcome.error ? { error: outcome.error } : {}),
},
})
try {
const { rollbackFile } = await import("../checkpoints/rollback")
const outcome = await rollbackFile(task, result.data.checkpointId, result.data.filePath)
await provider.postMessageToWebview({
type: "checkpointRollbackResult",
checkpointRollbackResult: {
cardTs: result.data.cardTs,
filePath: outcome.filePath,
success: outcome.success,
...(outcome.error ? { error: outcome.error } : {}),
},
})
} catch (error) {
// Correlated failure: a throw between the request and the result post
// (import, journal read, git restore) would otherwise leave the
// requesting card pending forever.
await provider.postMessageToWebview({
type: "checkpointRollbackResult",
checkpointRollbackResult: {
cardTs: result.data.cardTs,
filePath: result.data.filePath,
success: false,
error: `Rollback failed: ${error instanceof Error ? error.message : String(error)}`,
},
})
}
} else {
// No active task: the rollback cannot run. Post the correlated
// failure so the requesting card can clear its pending state
Expand All @@ -1635,7 +1650,7 @@ export const webviewMessageHandler = async (
cardTs: result.data.cardTs,
filePath: result.data.filePath,
success: false,
error: "No active task to roll back from.",
error: t("common:errors.message.no_active_task_to_roll_back"),
},
})
}
Expand All @@ -1652,18 +1667,30 @@ export const webviewMessageHandler = async (

if (task) {
// Lazy import (see the checkpointRollbackFile case above).
const { rollbackStep } = await import("../checkpoints/rollback")
const outcome = await rollbackStep(task, result.data.filePaths, result.data.checkpointId)
const firstFailure = outcome.files.find((file) => !file.success)
await provider.postMessageToWebview({
type: "checkpointRollbackResult",
checkpointRollbackResult: {
cardTs: result.data.cardTs,
success: outcome.files.every((file) => file.success),
...(firstFailure ? { error: firstFailure.error } : {}),
files: outcome.files,
},
})
try {
const { rollbackStep } = await import("../checkpoints/rollback")
const outcome = await rollbackStep(task, result.data.filePaths, result.data.checkpointId)
const firstFailure = outcome.files.find((file) => !file.success)
await provider.postMessageToWebview({
type: "checkpointRollbackResult",
checkpointRollbackResult: {
cardTs: result.data.cardTs,
success: outcome.files.every((file) => file.success),
...(firstFailure ? { error: firstFailure.error } : {}),
files: outcome.files,
},
})
} catch (error) {
// Correlated failure (see the checkpointRollbackFile case).
await provider.postMessageToWebview({
type: "checkpointRollbackResult",
checkpointRollbackResult: {
cardTs: result.data.cardTs,
success: false,
error: `Rollback failed: ${error instanceof Error ? error.message : String(error)}`,
},
})
}
} else {
// No active task: post the correlated failure so the requesting
// card can clear its pending state.
Expand All @@ -1672,7 +1699,7 @@ export const webviewMessageHandler = async (
checkpointRollbackResult: {
cardTs: result.data.cardTs,
success: false,
error: "No active task to roll back from.",
error: t("common:errors.message.no_active_task_to_roll_back"),
},
})
}
Expand All @@ -1692,19 +1719,33 @@ export const webviewMessageHandler = async (

if (task) {
// Lazy import (see the checkpointRollbackFile case above).
const { restoreLatestFile } = await import("../checkpoints/rollback")
const outcome = await restoreLatestFile(task, result.data.filePath)
await provider.postMessageToWebview({
type: "checkpointRollbackResult",
checkpointRollbackResult: {
cardTs: result.data.cardTs,
kind: "restore-latest",
filePath: outcome.filePath,
success: outcome.success,
...(outcome.noOp ? { noOp: true } : {}),
...(outcome.error ? { error: outcome.error } : {}),
},
})
try {
const { restoreLatestFile } = await import("../checkpoints/rollback")
const outcome = await restoreLatestFile(task, result.data.filePath)
await provider.postMessageToWebview({
type: "checkpointRollbackResult",
checkpointRollbackResult: {
cardTs: result.data.cardTs,
kind: "restore-latest",
filePath: outcome.filePath,
success: outcome.success,
...(outcome.noOp ? { noOp: true } : {}),
...(outcome.error ? { error: outcome.error } : {}),
},
})
} catch (error) {
// Correlated failure (see the checkpointRollbackFile case).
await provider.postMessageToWebview({
type: "checkpointRollbackResult",
checkpointRollbackResult: {
cardTs: result.data.cardTs,
kind: "restore-latest",
filePath: result.data.filePath,
success: false,
error: `Restore failed: ${error instanceof Error ? error.message : String(error)}`,
},
})
}
} else {
// No active task: post the correlated failure so the requesting
// card can clear its pending state.
Expand All @@ -1715,7 +1756,7 @@ export const webviewMessageHandler = async (
kind: "restore-latest",
filePath: result.data.filePath,
success: false,
error: "No active task to restore from.",
error: t("common:errors.message.no_active_task_to_restore"),
},
})
}
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/ca/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/i18n/locales/de/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@
},
"message": {
"no_active_task_to_delete": "No active task to delete messages from",
"no_active_task_to_roll_back": "No active task to roll back from",
"no_active_task_to_restore": "No active task to restore from",
"invalid_timestamp_for_deletion": "Invalid message timestamp for deletion",
"cannot_delete_missing_timestamp": "Cannot delete message: missing timestamp",
"cannot_delete_invalid_timestamp": "Cannot delete message: invalid timestamp",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/es/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/i18n/locales/fr/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/i18n/locales/hi/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/i18n/locales/id/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/i18n/locales/it/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/i18n/locales/ja/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/i18n/locales/ko/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/i18n/locales/nl/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/i18n/locales/pl/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/i18n/locales/pt-BR/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/i18n/locales/ru/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading