Files
paseo/packages/app/src/stores/draft-store/migration.test.ts
T
a8ebd390fa feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* refactor(forge): forge-neutral foundation (GitHub-only)

Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged.

- Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry.
- GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved.
- forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client).
- createPullRequest reports typed CLI/auth errors instead of a generic message.
- forge-resolver host/remote caches are LRU-bounded.
- Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com.
- Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology.
- Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs.

* feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals

GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals.

- threadIsResolved is part of the neutral timeline item.
- Pipeline load failures show an error instead of an empty section.
- Manual pipeline jobs render as pending.
- Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request).

* feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg)

One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts.

- CI status aggregates commit statuses and Actions runs together.
- Gitea's terminal "warning" state maps to failure on server and client.
- Gitea Actions check details are reachable from the PR pane by workflowRunId.

* refactor(forge): localize compatibility handling

* test(forge): expect normalized GitLab facts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-17 15:03:26 +08:00

267 lines
7.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import { migratePersistedState, type MigrateLegacyImages } from "./migration";
import { isAttachmentMetadata, type DraftRecord } from "./state";
const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) =>
images.filter(isAttachmentMetadata);
function activeDraft(
text: string,
updatedAt: number,
attachments: UserComposerAttachment[] = [],
): DraftRecord {
return {
input: { text, attachments },
lifecycle: "active",
updatedAt,
version: 1,
};
}
function githubIssueAttachment(
number: number,
): Extract<UserComposerAttachment, { kind: "github_issue" }> {
return {
kind: "github_issue",
item: {
kind: "issue",
number,
title: `Review item ${number}`,
url: `https://example.com/issues/${number}`,
state: "open",
body: null,
labels: [],
},
};
}
function githubPrAttachment(
number: number,
): Extract<UserComposerAttachment, { kind: "github_pr" }> {
return {
kind: "github_pr",
item: {
kind: "change_request",
number,
title: `Review item ${number}`,
url: `https://example.com/pulls/${number}`,
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature/legacy",
},
};
}
function workspaceReviewAttachment(): Extract<ComposerAttachment, { kind: "review" }> {
return {
kind: "review",
reviewDraftKey: "review:key",
commentCount: 1,
attachment: {
type: "review",
mimeType: "application/paseo-review",
cwd: "/repo",
mode: "uncommitted",
baseRef: null,
comments: [
{
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body: "Please simplify this.",
context: {
hunkHeader: "@@ -40,1 +40,1 @@",
targetLine: {
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
lines: [
{
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
],
},
},
],
},
};
}
describe("draft-store migration", () => {
it("promotes the newest legacy New Workspace draft into the singleton surface", async () => {
const forkDraft = activeDraft("fork context", 1700000000003);
const agentDraft = activeDraft("agent prompt", 1700000000004);
const migrated = await migratePersistedState(
{
drafts: {
"new-workspace:server-a:/project/older": activeDraft(
"older new workspace prompt",
1700000000001,
),
"new-workspace:server-b:/project/newer": activeDraft(
"newer new workspace prompt",
1700000000002,
),
"new-workspace:draft:fork-1": forkDraft,
"agent:server-a:agent-1": agentDraft,
},
createModalDraft: null,
},
{ migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000005 },
);
expect(migrated.drafts).toEqual({
"new-workspace": activeDraft("newer new workspace prompt", 1700000000002),
"new-workspace:draft:fork-1": forkDraft,
"agent:server-a:agent-1": agentDraft,
});
});
it("drops unowned checkout PR context when promoting a scoped New Workspace draft", async () => {
const issue = githubIssueAttachment(101);
const migrated = await migratePersistedState(
{
drafts: {
"new-workspace:server-a:/project/a": activeDraft("keep the prompt", 2, [
issue,
githubPrAttachment(202),
]),
},
createModalDraft: null,
},
{ migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 3 },
);
expect(migrated.drafts["new-workspace"]?.input).toEqual({
text: "keep the prompt",
attachments: [issue],
});
});
it("normalizes legacy image metadata into image attachments and strips persisted preview URLs", async () => {
const migrated = await migratePersistedState(
{
drafts: {
"agent:server:agent": {
input: {
text: "hello",
images: [
{
id: "att-1",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/tmp/att-1.png",
createdAt: 1700000000000,
previewUri: "asset://should-not-persist",
},
],
},
lifecycle: "active",
updatedAt: 1700000000001,
version: 1,
},
},
createModalDraft: null,
},
{ migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 },
);
expect(migrated.drafts["agent:server:agent"]?.input).toEqual({
text: "hello",
attachments: [
{
kind: "image",
metadata: {
id: "att-1",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/tmp/att-1.png",
createdAt: 1700000000000,
},
},
],
});
});
it("hydrates old persisted drafts that still include cwd", async () => {
const original = {
drafts: {
"agent:server:agent": {
input: {
text: "hello",
attachments: [
{
kind: "image",
metadata: {
id: "att-1",
mimeType: "image/jpeg",
storageType: "web-indexeddb",
storageKey: "att-1",
createdAt: 1700000000000,
},
},
],
cwd: "/repo",
},
lifecycle: "active",
updatedAt: 1700000000001,
version: 2,
},
},
createModalDraft: null,
};
const ports = { migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 };
const once = await migratePersistedState(original, ports);
const twice = await migratePersistedState(once, ports);
expect(twice).toEqual(once);
expect(twice.drafts["agent:server:agent"]?.input).toEqual({
text: "hello",
attachments: [
{
kind: "image",
metadata: {
id: "att-1",
mimeType: "image/jpeg",
storageType: "web-indexeddb",
storageKey: "att-1",
createdAt: 1700000000000,
},
},
],
});
});
it("rejects workspace review attachments from migrated draft attachments", async () => {
const migrated = await migratePersistedState(
{
drafts: {
"agent:server:agent": {
input: {
text: "hello",
attachments: [workspaceReviewAttachment()],
},
lifecycle: "active",
updatedAt: 1700000000001,
version: 2,
},
},
createModalDraft: null,
},
{ migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 },
);
expect(migrated.drafts["agent:server:agent"]?.input.attachments).toEqual([]);
});
});