1
53import { spawn } from "node:child_process";
54import { randomBytes } from "node:crypto";
55import { writeFileSync, rmSync } from "node:fs";
56import path from "node:path";
57import type { APIRequestContext, Page } from "@playwright/test";
58import { test, expect } from "../fixtures";
59
60
61
62
63
64type Json = Record<string, unknown>;
65interface Run {
66 id: string;
67 status: string;
68 triggerType: string | null;
69 retryCount: number;
70 error: { code?: string; message?: string } | null;
71 createdAt: string;
72 startedAt: string | null;
73 finishedAt: string | null;
74 output?: unknown;
75}
76
77const j = async (res: { json(): Promise<unknown> }): Promise<Json> =>
78 (await res.json().catch(() => ({}))) as Json;
79
80async function expectOk(res: { status(): number; text(): Promise<string> }, what: string): Promise<Json> {
81 const status = res.status();
82 if (status < 200 || status >= 300) throw new Error(`${what} → ${status}: ${await res.text()}`);
83 return j(res);
84}
85
86async function createDatabasePage(ctx: APIRequestContext, wsId: string, title: string): Promise<{ pageId: string; databaseId: string }> {
87
88
89
90 const res = await ctx.post(`/api/v1/workspaces/${wsId}/pages`, { data: { title, type: "database" } });
91 const body = await expectOk(res, `create database page "${title}"`);
92 const databaseId = body.databaseId;
93 if (typeof databaseId !== "string" || !databaseId) throw new Error(`createDatabasePage: no databaseId in response: ${JSON.stringify(body)}`);
94 return { pageId: String(body.id), databaseId };
95}
96
97async function addPropertyRaw(ctx: APIRequestContext, dbId: string, body: { name: string; type: string; config?: unknown }): Promise<void> {
98 await expectOk(
99 await ctx.post(`/api/v1/databases/${dbId}/properties`, { data: body }),
100 `add property ${body.name}`
101 );
102}
103
104
105async function findPropertyId(ctx: APIRequestContext, dbId: string, name: string): Promise<string> {
106 const res = await ctx.get(`/api/v1/databases/${dbId}/properties`);
107 const body = await expectOk(res, `list properties of ${dbId}`);
108 const list = (Array.isArray(body) ? body : (body.data ?? body.properties ?? [])) as Json[];
109 const p = list.find((x) => x.name === name);
110 if (!p?.id) throw new Error(`property "${name}" not found in ${dbId}: ${JSON.stringify(list.map((x) => x.name))}`);
111 return String(p.id);
112}
113
114async function createRecordRaw(ctx: APIRequestContext, dbId: string, properties: Record<string, unknown>): Promise<Json> {
115 const res = await ctx.post(`/api/v1/databases/${dbId}/records`, { data: { properties } });
116 const body = await expectOk(res, `create record in ${dbId}`);
117 const rec = (body.record ?? body) as Json;
118 if (!rec.id) throw new Error(`createRecordRaw: no record id: ${JSON.stringify(body)}`);
119 return rec;
120}
121
122async function getRecord(ctx: APIRequestContext, recordId: string): Promise<Json> {
123 const res = await ctx.get(`/api/v1/records/${recordId}`);
124 return expectOk(res, `get record ${recordId}`);
125}
126
127
128function selectValue(record: Json, propertyId: string): string | null {
129 const props = record.properties as Record<string, unknown> | undefined;
130 const v = props?.[propertyId];
131 if (v == null) return null;
132 if (typeof v === "object" && "optionId" in (v as Json)) return String((v as Json).optionId);
133 return String(v);
134}
135
136
151async function collapseSidebarIfNarrow(page: Page): Promise<void> {
152 const vp = page.viewportSize();
153 if (!vp || vp.width >= 768) return;
154 if ((await page.locator("aside").count()) === 0) return;
155 await page.getByRole("button", { name: "Toggle sidebar" }).click();
156 await page.waitForTimeout(300);
157}
158
159
160
161interface AutomationDetail {
162 id: string;
163 name: string;
164 status: string;
165 databaseId: string | null;
166 triggers: string[];
167 hasCondition: boolean;
168 actions: string[];
169}
170
171async function createAutomation(ctx: APIRequestContext, wsId: string, input: Json): Promise<AutomationDetail> {
172 const res = await ctx.post(`/api/v1/workspaces/${wsId}/automations`, { data: input });
173 const body = await expectOk(res, `create automation "${input.name}"`);
174 if (!body.id) throw new Error(`createAutomation: no id: ${JSON.stringify(body)}`);
175 return body as unknown as AutomationDetail;
176}
177
178async function setTriggers(ctx: APIRequestContext, autoId: string, triggers: Json[]): Promise<void> {
179 await expectOk(await ctx.put(`/api/v1/automations/${autoId}/triggers`, { data: { triggers } }), `set triggers on ${autoId}`);
180}
181
182async function setCondition(ctx: APIRequestContext, autoId: string, condition: Json | null): Promise<void> {
183 await expectOk(await ctx.put(`/api/v1/automations/${autoId}/conditions`, { data: { condition } }), `set condition on ${autoId}`);
184}
185
186async function setActions(ctx: APIRequestContext, autoId: string, actions: Json[]): Promise<void> {
187 await expectOk(await ctx.put(`/api/v1/automations/${autoId}/actions`, { data: { actions } }), `set actions on ${autoId}`);
188}
189
190async function setStatus(ctx: APIRequestContext, autoId: string, status: string): Promise<void> {
191 await expectOk(await ctx.post(`/api/v1/automations/${autoId}/status`, { data: { status } }), `set status ${status} on ${autoId}`);
192}
193
194async function listRuns(ctx: APIRequestContext, autoId: string): Promise<Run[]> {
195 const res = await ctx.get(`/api/v1/automations/${autoId}/runs?limit=200`);
196 const body = await expectOk(res, `list runs of ${autoId}`);
197 return (body.data as Run[]) ?? [];
198}
199
200
207async function listRunsTolerant(ctx: APIRequestContext, autoId: string): Promise<Run[] | null> {
208 const res = await ctx.get(`/api/v1/automations/${autoId}/runs?limit=200`);
209 if (res.status() === 429) {
210 const retryAfter = Number(res.headers()["retry-after"] ?? 60);
211 const waitMs = Math.min(Math.max(retryAfter, 1) * 1000, 65_000);
212 await sleep(waitMs);
213 return null;
214 }
215 const body = await expectOk(res, `list runs of ${autoId}`);
216 return (body.data as Run[]) ?? [];
217}
218
219async function getRun(ctx: APIRequestContext, autoId: string, runId: string): Promise<Json> {
220 const res = await ctx.get(`/api/v1/automations/${autoId}/runs/${runId}`);
221 return expectOk(res, `get run ${runId}`);
222}
223
224const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
225
226
227async function waitForRun(
228 ctx: APIRequestContext,
229 autoId: string,
230 pred: (runs: Run[]) => Run | undefined,
231 opts: { timeoutMs?: number; intervalMs?: number; what?: string } = {}
232): Promise<Run> {
233 const { timeoutMs = 30_000, intervalMs = 1_000 } = opts;
234 const t0 = Date.now();
235 for (;;) {
236 const runs = await listRunsTolerant(ctx, autoId);
237 if (runs === null) continue;
238 const m = pred(runs);
239 if (m) return m;
240 if (Date.now() - t0 > timeoutMs) {
241 throw new Error(
242 `${opts.what ?? "waitForRun"}: timeout after ${timeoutMs} ms; runs = ${JSON.stringify(runs.map((r) => ({ id: r.id.slice(-6), status: r.status })))}`
243 );
244 }
245 await sleep(intervalMs);
246 }
247}
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264async function reconcileSchedules(): Promise<{ added: number; removed: number }> {
265 const dsn = process.env.DATABASE_URL;
266 const redisUrl = process.env.REDIS_URL ?? "redis://localhost:6379";
267 if (!dsn) throw new Error("reconcileSchedules: DATABASE_URL is not set (the schedule E2E test needs the DB DSN).");
268
269 const here = __dirname;
270 const workerSrc = path.resolve(here, "..", "..", "..", "apps", "worker", "src");
271 const childScript = path.join(here, `.reconcile-child-${randomBytes(4).toString("hex")}.mts`);
272
273 const tsxCli = path.resolve(here, "..", "..", "..", "node_modules", "tsx", "dist", "cli.mjs");
274 writeFileSync(
275 childScript,
276 [
277 "import { PrismaClient } from \"@prisma/client\";",
278 "import { Redis } from \"ioredis\";",
279 "const dir = process.env.WORKER_SRC_DIR!;",
280 "const { reconcileScheduleAutomations } = await import(\"file://\" + dir + \"/processors/automations/schedule.ts\");",
281 "const prisma = new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL! } } });",
282 "const redis = new Redis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });",
283 "const r = await reconcileScheduleAutomations(redis, prisma);",
284
285
286
287
288 "console.log(\"RECONCILE_DONE \" + JSON.stringify(r));",
289 "await redis.quit();",
290 "await prisma.$disconnect();",
291 ""
292 ].join("\n"),
293 "utf8"
294 );
295 try {
296 const out: string[] = [];
297 const err: string[] = [];
298 await new Promise<void>((resolve, reject) => {
299 const child = spawn(process.execPath, [tsxCli, childScript], {
300 cwd: here,
301 stdio: ["ignore", "pipe", "pipe"],
302 env: { ...process.env, DATABASE_URL: dsn, REDIS_URL: redisUrl, WORKER_SRC_DIR: workerSrc }
303 });
304 const t = setTimeout(() => {
305 child.kill("SIGKILL");
306 reject(new Error(`reconcileSchedules: child timed out; stderr=${err.join("")}`));
307 }, 30_000);
308 child.stdout.on("data", (d) => out.push(String(d)));
309 child.stderr.on("data", (d) => err.push(String(d)));
310 child.on("close", (code) => {
311 clearTimeout(t);
312 if (code === 0) resolve();
313 else reject(new Error(`reconcileSchedules: child exited ${code}; stderr=${err.join("")}`));
314 });
315 });
316 const stdout = out.join("");
317 const line = stdout.split("\n").find((l) => l.startsWith("RECONCILE_DONE "));
318 if (!line) {
319 throw new Error(`reconcileSchedules: no RECONCILE_DONE marker in child stdout; stdout=${JSON.stringify(stdout.slice(-500))}; stderr=${err.join("").slice(-500)}`);
320 }
321 return JSON.parse(line.slice("RECONCILE_DONE ".length)) as { added: number; removed: number };
322 } finally {
323 rmSync(childScript, { force: true });
324 }
325}
326
327
328async function findNotification(ctx: APIRequestContext, bodyIncludes: string): Promise<Json | null> {
329 const res = await ctx.get(`/api/v1/notifications?limit=100`);
330 const body = await expectOk(res, "list notifications");
331 const list = (Array.isArray(body) ? body : (body.data ?? [])) as Json[];
332 return list.find((n) => typeof n.body === "string" && n.body.includes(bodyIncludes)) ?? null;
333}
334
335
336
337
338
339test("record.created triggers UPDATE_RECORD + notification; property changed in < 10 s; run + logs visible in UI", async ({
340 api,
341 authPage,
342 freshWorkspace
343}) => {
344 const ws = await freshWorkspace("Auto E2E A");
345 const { databaseId } = await createDatabasePage(api, ws.id, "Tickets");
346 await addPropertyRaw(api, databaseId, {
347 name: "Stage",
348 type: "SELECT",
349 config: { options: [{ name: "A" }, { name: "B" }] }
350 });
351 const statusPropId = await findPropertyId(api, databaseId, "Stage");
352
353 const auto = await createAutomation(api, ws.id, {
354 name: "E2E: promote new tickets",
355 databaseId,
356 triggers: [{ type: "RECORD_CREATED", config: { databaseId } }],
357 actions: [
358 { type: "UPDATE_RECORD", config: { recordId: "from_trigger", propertyValues: { Stage: "B" } } },
359 { type: "CREATE_NOTIFICATION", config: { to: "automation_creator", type: "DATABASE_CHANGE", body: "E2E auto: ticket promoted to B" } }
360 ]
361 });
362 await setStatus(api, auto.id, "ACTIVE");
363
364
365 const createdMs = Date.now();
366 const rec = await createRecordRaw(api, databaseId, { [statusPropId]: "A" });
367 const recordId = String(rec.id);
368
369 const run = await waitForRun(
370 api,
371 auto.id,
372 (runs) => runs.find((r) => r.status === "SUCCEEDED" || r.status === "FAILED" || r.status === "DEAD_LETTER"),
373 { timeoutMs: 10_000, what: "terminal run within the 10 s exit criterion" }
374 );
375 expect(run.status, `run ${run.id} terminal status (error=${JSON.stringify(run.error)})`).toBe("SUCCEEDED");
376 const elapsedMs = Date.now() - createdMs;
377 expect(elapsedMs, `record-created → SUCCEEDED must be < 10 s (was ${elapsedMs} ms)`).toBeLessThan(10_000);
378
379
380 const after = await getRecord(api, recordId);
381 expect(selectValue(after, statusPropId)).toBe("B");
382
383
384 expect(await findNotification(api, "E2E auto: ticket promoted to B")).not.toBeNull();
385
386
387
388
389
390
391 await authPage.goto(`/w/${ws.id}/settings/automations`);
392 await expect(authPage.getByTestId("automation-list")).toBeVisible({ timeout: 15_000 });
393 await expect(authPage.getByTestId(`automation-row-${auto.id}`)).toBeVisible({ timeout: 15_000 });
394
395 await expect(authPage.getByTestId(`automation-row-${auto.id}`).getByText("succeeded")).toBeVisible();
396
397 await collapseSidebarIfNarrow(authPage);
398 await authPage.getByTestId(`automation-runs-${auto.id}`).click();
399 await expect(authPage.getByTestId(`automation-run-${run.id}`)).toBeVisible({ timeout: 15_000 });
400 await authPage.getByTestId(`automation-run-${run.id}`).click();
401 const logPanel = authPage.getByTestId(`automation-run-log-${run.id}`);
402 await expect(logPanel).toBeVisible({ timeout: 15_000 });
403
404 await expect(logPanel).toContainText("action 1/2 UPDATE_RECORD started");
405 await expect(logPanel).toContainText("action 2/2 CREATE_NOTIFICATION started");
406 await expect(logPanel).toContainText("run completed");
407});
408
409
410
411
412
413test("condition gate: non-matching record skipped, matching record executes", async ({ api, freshWorkspace }) => {
414 const ws = await freshWorkspace("Auto E2E B");
415 const { databaseId } = await createDatabasePage(api, ws.id, "Leads");
416 await addPropertyRaw(api, databaseId, {
417 name: "Stage",
418 type: "SELECT",
419 config: { options: [{ name: "A" }, { name: "B" }] }
420 });
421 const statusPropId = await findPropertyId(api, databaseId, "Stage");
422
423 const auto = await createAutomation(api, ws.id, {
424 name: "E2E: gate on Stage = B",
425 databaseId,
426 triggers: [{ type: "RECORD_CREATED", config: { databaseId } }],
427 condition: { field: { ref: "property", name: "Stage" }, operator: "eq", value: "B" },
428 actions: [{ type: "UPDATE_RECORD", config: { recordId: "from_trigger", propertyValues: { Stage: "A" } } }]
429 });
430 await setStatus(api, auto.id, "ACTIVE");
431
432
433
434 await createRecordRaw(api, databaseId, { [statusPropId]: "A" });
435 const skipped = await waitForRun(api, auto.id, (runs) => runs.find((r) => r.status === "SUCCEEDED"), {
436 timeoutMs: 15_000,
437 what: "skipped run"
438 });
439 const skippedDetail = await getRun(api, auto.id, skipped.id);
440 expect(skippedDetail.output, "output.skipped for the gated run").toEqual({ skipped: "conditions_not_met" });
441
442
443 await createRecordRaw(api, databaseId, { [statusPropId]: "B" });
444 const ran = await waitForRun(api, auto.id, (runs) => runs.find((r) => r.id !== skipped.id && r.status === "SUCCEEDED"), {
445 timeoutMs: 15_000,
446 what: "executed run"
447 });
448 const ranDetail = await getRun(api, auto.id, ran.id);
449 expect((ranDetail.output as Json)?.skipped, "the matching run must NOT be skipped").toBeUndefined();
450 const actions = (ranDetail.output as Json)?.actions as Json[];
451 expect(actions?.length).toBe(1);
452 expect(actions?.[0]?.type).toBe("UPDATE_RECORD");
453});
454
455
456
457
458
459test("schedule trigger: cron automation fires and creates a schedule run", async ({ api, freshWorkspace }) => {
460 test.setTimeout(120_000);
461 const ws = await freshWorkspace("Auto E2E C");
462 const auto = await createAutomation(api, ws.id, {
463 name: "E2E: hourly ping",
464 triggers: [{ type: "SCHEDULE", config: { cron: "* * * * *" } }],
465 actions: [{ type: "CREATE_NOTIFICATION", config: { to: "automation_creator", type: "AUTOMATION", body: "E2E cron tick" } }]
466 });
467 await setStatus(api, auto.id, "ACTIVE");
468
469
470
471 const { added } = await reconcileSchedules();
472 expect(added, "the reconciler must register the new repeatable").toBeGreaterThanOrEqual(1);
473
474 const run = await waitForRun(
475 api,
476 auto.id,
477 (runs) => runs.find((r) => r.triggerType === "schedule" && (r.status === "SUCCEEDED" || r.status === "FAILED" || r.status === "DEAD_LETTER")),
478 { timeoutMs: 95_000, intervalMs: 2_000, what: "cron fire (≤ 60 s resolution + execution)" }
479 );
480 expect(run.status).toBe("SUCCEEDED");
481 expect(await findNotification(api, "E2E cron tick")).not.toBeNull();
482
483
484
485
486 await setStatus(api, auto.id, "PAUSED");
487 await reconcileSchedules();
488});
489
490
491
492
493
494test("PAUSED automation stops matching; re-activating resumes", async ({ api, freshWorkspace }) => {
495 const ws = await freshWorkspace("Auto E2E D");
496 const { databaseId } = await createDatabasePage(api, ws.id, "Items");
497 const auto = await createAutomation(api, ws.id, {
498 name: "E2E: pausable",
499 databaseId,
500 triggers: [{ type: "RECORD_CREATED", config: { databaseId } }],
501 actions: [{ type: "CREATE_NOTIFICATION", config: { to: "automation_creator", type: "AUTOMATION", body: "E2E pause test tick" } }]
502 });
503 await setStatus(api, auto.id, "ACTIVE");
504
505
506 await createRecordRaw(api, databaseId, {});
507 const first = await waitForRun(api, auto.id, (runs) => runs.find((r) => r.status === "SUCCEEDED"), {
508 timeoutMs: 15_000,
509 what: "first (active) run"
510 });
511
512
513 await setStatus(api, auto.id, "PAUSED");
514 await sleep(1_000);
515 await createRecordRaw(api, databaseId, {});
516 await sleep(10_000);
517 const whilePaused = await listRuns(api, auto.id);
518 expect(whilePaused, "no new run while PAUSED").toHaveLength(1);
519 expect(whilePaused[0]?.id).toBe(first.id);
520
521
522 await setStatus(api, auto.id, "ACTIVE");
523 await createRecordRaw(api, databaseId, {});
524 await waitForRun(api, auto.id, (runs) => runs.find((r) => r.id !== first.id && r.status === "SUCCEEDED"), {
525 timeoutMs: 15_000,
526 what: "run after re-activation"
527 });
528});
529
530
531
532
533
534test("failing action (dead TEST-NET webhook) → FAILED run with error in UI; retry re-runs the run", async ({
535 api,
536 authPage,
537 freshWorkspace
538}) => {
539 test.setTimeout(90_000);
540 const ws = await freshWorkspace("Auto E2E E");
541 const { databaseId } = await createDatabasePage(api, ws.id, "Outbox");
542 const auto = await createAutomation(api, ws.id, {
543 name: "E2E: failing webhook",
544 databaseId,
545 triggers: [{ type: "RECORD_CREATED", config: { databaseId } }],
546 actions: [{ type: "SEND_WEBHOOK", config: { url: "https://192.0.2.1/hook" } }]
547 });
548 await setStatus(api, auto.id, "ACTIVE");
549
550
551
552
553 await createRecordRaw(api, databaseId, {});
554 const run = await waitForRun(
555 api,
556 auto.id,
557 (runs) => runs.find((r) => r.status === "FAILED" || r.status === "SUCCEEDED" || r.status === "DEAD_LETTER"),
558 { timeoutMs: 45_000, what: "terminal (failed) run" }
559 );
560 expect(run.status, `terminal status (error=${JSON.stringify(run.error)})`).toBe("FAILED");
561 expect(run.error, "the run error must be stored").not.toBeNull();
562
563
564
565
566
567
568 await authPage.goto(`/w/${ws.id}/settings/automations`);
569 await expect(authPage.getByTestId(`automation-row-${auto.id}`)).toBeVisible({ timeout: 15_000 });
570 await collapseSidebarIfNarrow(authPage);
571 await authPage.getByTestId(`automation-runs-${auto.id}`).click();
572 await expect(authPage.getByTestId(`automation-run-${run.id}`)).toBeVisible({ timeout: 15_000 });
573 await expect(authPage.getByTestId(`automation-run-${run.id}`).getByText("failed")).toBeVisible();
574 await authPage.getByTestId(`automation-run-${run.id}`).click();
575 const logPanel = authPage.getByTestId(`automation-run-log-${run.id}`);
576 await expect(logPanel).toBeVisible({ timeout: 15_000 });
577 await expect(logPanel).toContainText("failed");
578 const retryButton = authPage.getByTestId(`automation-retry-run-${run.id}`);
579 await expect(retryButton).toBeVisible();
580
581
582 const before = (await getRun(api, auto.id, run.id)) as Json;
583 const firstFinishedAt = before.finishedAt as string;
584 await retryButton.click();
585
586
587 await waitForRun(
588 api,
589 auto.id,
590 (runs) => runs.find((r) => r.id === run.id && r.status === "FAILED" && (r.finishedAt ?? "") > firstFinishedAt),
591 { timeoutMs: 60_000, what: "re-run after UI retry" }
592 );
593});
594
595
596
597
598
599test("self-trigger: an automation's own record.updated re-match is skipped, no loop", async ({ api, freshWorkspace }) => {
600 test.setTimeout(90_000);
601 const ws = await freshWorkspace("Auto E2E F");
602 const { databaseId } = await createDatabasePage(api, ws.id, "Loop");
603 await addPropertyRaw(api, databaseId, {
604 name: "Stage",
605 type: "SELECT",
606 config: { options: [{ name: "A" }, { name: "B" }] }
607 });
608 const statusPropId = await findPropertyId(api, databaseId, "Stage");
609
610 const auto = await createAutomation(api, ws.id, {
611 name: "E2E: self-trigger guard",
612 databaseId,
613
614
615
616
617
618
619
620
621
622
623 triggers: [{ type: "RECORD_UPDATED", config: { databaseId } }],
624 actions: [{ type: "UPDATE_RECORD", config: { recordId: "from_trigger", propertyValues: { Stage: "A" } } }]
625 });
626 await setStatus(api, auto.id, "ACTIVE");
627
628
629
630
631
632
633
634 const rec = await createRecordRaw(api, databaseId, { [statusPropId]: "A" });
635 await expectOk(
636 await api.patch(`/api/v1/records/${String(rec.id)}`, { data: { properties: { [statusPropId]: "B" } } }),
637 "user edit (B) that starts the chain"
638 );
639
640
641 const isTerminal = (r: Run) =>
642 r.status === "SUCCEEDED" || r.status === "FAILED" || r.status === "DEAD_LETTER";
643 await waitForRun(api, auto.id, (runs) => (runs.length >= 2 && isTerminal(runs[0]) ? runs[0] : undefined), {
644 timeoutMs: 30_000,
645 what: "the second run (the re-matched record.updated echo) reaching terminal"
646 });
647 const runsNow = await listRuns(api, auto.id);
648 expect(runsNow.length, "exactly 2 runs: the update ran, its own echo was skipped").toBe(2);
649
650 const skipped = await getRun(api, auto.id, runsNow[0].id);
651 expect(skipped.output, "self_trigger skip output").toEqual({ skipped: "self_trigger" });
652
653
654 await sleep(8_000);
655 expect((await listRuns(api, auto.id)).length, "no third run (loop prevented)").toBe(2);
656});
657
658
659
660
661
662test("manual run on a DRAFT automation executes; full per-action logs render in the UI", async ({
663 api,
664 authPage,
665 freshWorkspace
666}) => {
667 const ws = await freshWorkspace("Auto E2E G");
668 const { databaseId } = await createDatabasePage(api, ws.id, "Manual");
669 await addPropertyRaw(api, databaseId, {
670 name: "Stage",
671 type: "SELECT",
672 config: { options: [{ name: "A" }, { name: "B" }] }
673 });
674 const statusPropId = await findPropertyId(api, databaseId, "Stage");
675 const seed = await createRecordRaw(api, databaseId, { [statusPropId]: "A" });
676
677
678 const auto = await createAutomation(api, ws.id, {
679 name: "E2E: manual draft",
680 databaseId,
681 actions: [{ type: "UPDATE_RECORD", config: { recordId: String(seed.id), propertyValues: { Stage: "B" } } }]
682 });
683 expect(auto.status).toBe("DRAFT");
684
685 const res = await api.post(`/api/v1/automations/${auto.id}/run`, { data: {} });
686 expect(res.status(), "manual run → 202").toBe(202);
687 const body = await j(res);
688 const runId = String(body.runId);
689 expect(runId).toBeTruthy();
690
691 const run = await waitForRun(
692 api,
693 auto.id,
694 (runs) => runs.find((r) => r.id === runId && (r.status === "SUCCEEDED" || r.status === "FAILED")),
695 { timeoutMs: 30_000, what: "manual run terminal" }
696 );
697 expect(run.status).toBe("SUCCEEDED");
698 expect(selectValue(await getRecord(api, String(seed.id)), statusPropId)).toBe("B");
699
700
701
702
703
704
705 await authPage.goto(`/w/${ws.id}/settings/automations`);
706 await expect(authPage.getByTestId(`automation-row-${auto.id}`)).toBeVisible({ timeout: 15_000 });
707 await collapseSidebarIfNarrow(authPage);
708 await authPage.getByTestId(`automation-runs-${auto.id}`).click();
709 await expect(authPage.getByTestId(`automation-run-${runId}`)).toBeVisible({ timeout: 15_000 });
710 await authPage.getByTestId(`automation-run-${runId}`).click();
711 const logPanel = authPage.getByTestId(`automation-run-log-${runId}`);
712 await expect(logPanel).toBeVisible({ timeout: 15_000 });
713 await expect(logPanel).toContainText("action 1/1 UPDATE_RECORD started");
714 await expect(logPanel).toContainText("action 1/1 UPDATE_RECORD done");
715 await expect(logPanel).toContainText("run completed");
716});
717