214 lines
6.9 KiB
TypeScript
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" },
|
|
});
|
|
});
|
|
});
|