Files

214 lines
6.9 KiB
TypeScript

import { convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import schema from "./schema";
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const identityA = { tokenIdentifier: "https://convex.test|timeline-a" };
const identityB = { tokenIdentifier: "https://convex.test|timeline-b" };
const newTest = () => convexTest({ modules, schema });
describe("timeline", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("persists a prompt once and creates the deterministic first Work flow", async () => {
const t = newTest();
await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
const first = await t
.withIdentity(identityA)
.mutation(api.timeline.sendPrompt, {
clientIdempotencyKey: "prompt-1",
scope: { kind: "global" },
text: "Improve the onboarding flow",
});
await vi.runAllTimersAsync();
await t.finishInProgressScheduledFunctions();
const duplicate = await t
.withIdentity(identityA)
.mutation(api.timeline.sendPrompt, {
clientIdempotencyKey: "prompt-1",
scope: { kind: "global" },
text: "Improve the onboarding flow",
});
expect(duplicate._id).toBe(first._id);
const global = await t
.withIdentity(identityA)
.query(api.timeline.getGlobal, {});
expect(
global.map((event: { readonly type: string }) => event.type)
).toEqual(["work.created", "assistant.acknowledged", "client.prompt"]);
const works = await t
.withIdentity(identityA)
.query(api.works.listRecent, {});
expect(works).toHaveLength(1);
expect(works[0]?.title).toBe("Improve the onboarding flow");
const workTimeline = await t
.withIdentity(identityA)
.query(api.timeline.getWork, { workId: works[0]!._id });
expect(
workTimeline.map((event: { readonly type: string }) => event.type)
).toEqual(["artifact.published", "thread.started"]);
expect(workTimeline[0]?.artifacts).toHaveLength(1);
});
test("does not expose a Work to another organization", async () => {
const t = newTest();
await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
const prompt = await t
.withIdentity(identityA)
.mutation(api.timeline.sendPrompt, {
clientIdempotencyKey: "prompt-private",
scope: { kind: "global" },
text: "Private work",
});
await vi.runAllTimersAsync();
await t.finishInProgressScheduledFunctions();
const works = await t
.withIdentity(identityA)
.query(api.works.listRecent, {});
expect(works).toHaveLength(1);
await t
.withIdentity(identityB)
.mutation(api.organizations.ensurePersonalOrganization, {});
await expect(
t
.withIdentity(identityB)
.query(api.timeline.getWork, { workId: works[0]!._id })
).rejects.toThrow(/Organization membership required/u);
expect(prompt.payload).toEqual({ text: "Private work" });
});
test("stores environment metadata and setup events against a Project", async () => {
const t = newTest();
await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
const project = await t.mutation(api.projects.persistPublicGitImport, {
remote: { documents: [], warnings: [] },
source: {
host: "github.com",
normalizedUrl: "https://github.com/example/setup-project",
projectName: "setup-project",
repositoryPath: "example/setup-project",
url: "https://github.com/example/setup-project.git",
},
userId: identityA.tokenIdentifier,
});
await t
.withIdentity(identityA)
.mutation(api.projectEnvironmentVariables.upsert, {
category: "required",
name: "DATABASE_URL",
projectId: project.id,
secretReference: "project-secret-database-url",
source: "detected",
status: "missing",
});
await t.mutation(api.projects.appendSetupEvent, {
correlationId: "setup-1",
idempotencyKey: "setup-event-1",
payload: { missing: ["DATABASE_URL"] },
projectId: project.id,
type: "project.setup.blocked",
visibility: "timeline",
});
const variables = await t
.withIdentity(identityA)
.query(api.projectEnvironmentVariables.list, { projectId: project.id });
expect(variables).toMatchObject([
{ name: "DATABASE_URL", status: "missing" },
]);
expect(variables[0]).not.toHaveProperty("secretValue");
const setup = await t
.withIdentity(identityA)
.query(api.projects.getSetup, { projectId: project.id });
expect(
setup.events.map((event: { readonly type: string }) => event.type)
).toEqual(["project.setup.blocked"]);
});
test("retries a failed runtime once while preserving stale-attempt safety", async () => {
const t = newTest();
const organization = await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
const project = await t.mutation(api.projects.persistPublicGitImport, {
remote: { documents: [], warnings: [] },
source: {
host: "github.com",
normalizedUrl: "https://github.com/example/retry-project",
projectName: "retry-project",
repositoryPath: "example/retry-project",
url: "https://github.com/example/retry-project.git",
},
userId: identityA.tokenIdentifier,
});
const runtimeId = await t.mutation(api.projectRuntimes.requestRuntime, {
idempotencyKey: `project:${project.id}:vm:v1`,
organizationId: organization._id,
projectId: project.id,
provider: "agentos",
});
await t.mutation(api.projectRuntimes.markStatus, {
attempt: 1,
lastError: "worker unavailable",
runtimeRowId: runtimeId,
status: "failed",
});
const retried = await t.mutation(api.projectRuntimes.retry, {
runtimeRowId: runtimeId,
});
expect(retried).toMatchObject({
retried: true,
runtime: { attempt: 2, status: "requested" },
});
expect(retried.runtime.lastError).toBeUndefined();
const staleWrite = await t.mutation(api.projectRuntimes.markStatus, {
attempt: 1,
lastError: "late failure",
runtimeRowId: runtimeId,
status: "failed",
});
expect(staleWrite).toMatchObject({ attempt: 2, status: "requested" });
const duplicateRetry = await t.mutation(api.projectRuntimes.retry, {
runtimeRowId: runtimeId,
});
expect(duplicateRetry).toMatchObject({
retried: false,
runtime: { attempt: 2, status: "requested" },
});
});
});