From c97c3b4d12e166091be9ea1de969a17d64c36ec2 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:07:19 +0200 Subject: [PATCH] fix(editor): Resolve nodes stuck on loading after execution in instance-ai preview (#28450) Co-authored-by: Claude Opus 4.6 (1M context) --- biome.jsonc | 3 +- packages/@n8n/instance-ai/docs/e2e-tests.md | 1 - .../instance-ai-test.controller.test.ts | 41 +++++-- .../instance-ai-test.controller.ts | 51 ++++++-- .../src/app/components/WorkflowPreview.vue | 2 +- .../__tests__/canvasPreview.utils.test.ts | 29 +++++ .../__tests__/useEventRelay.test.ts | 8 +- .../__tests__/useExecutionPushEvents.test.ts | 21 ++++ .../ai/instanceAi/canvasPreview.utils.ts | 22 +++- .../components/InstanceAiWorkflowPreview.vue | 72 +++++++++++ .../features/ai/instanceAi/useEventRelay.ts | 8 +- .../ai/instanceAi/useExecutionPushEvents.ts | 26 +++- ...known-host-POST-_v1_messages-8a23f6c2.json | 112 ++++++++++++++++++ ...known-host-POST-_v1_messages-4d1c93f7.json | 109 +++++++++++++++++ ...known-host-POST-_v1_messages-8a23f6c2.json | 112 ++++++++++++++++++ ...known-host-POST-_v1_messages-4d1c93f7.json | 109 +++++++++++++++++ ...known-host-POST-_v1_messages-8a23f6c2.json | 109 +++++++++++++++++ ...known-host-POST-_v1_messages-8a23f6c2.json | 112 ++++++++++++++++++ ...known-host-POST-_v1_messages-4d1c93f7.json | 112 ++++++++++++++++++ ...known-host-POST-_v1_messages-4d1c93f7.json | 109 +++++++++++++++++ ...known-host-POST-_v1_messages-8a23f6c2.json | 109 +++++++++++++++++ ...known-host-POST-_v1_messages-8a23f6c2.json | 112 ++++++++++++++++++ ...known-host-POST-_v1_messages-8a23f6c2.json | 112 ++++++++++++++++++ .../trace.jsonl | 10 ++ .../tests/e2e/instance-ai/fixtures.ts | 21 +++- .../instance-ai-workflow-preview.spec.ts | 29 +++++ 26 files changed, 1519 insertions(+), 42 deletions(-) create mode 100644 packages/testing/playwright/expectations/instance-ai/should-mark-all-nodes-as-success-after-execution-completes/1776326915171-unknown-host-POST-_v1_messages-8a23f6c2.json create mode 100644 packages/testing/playwright/expectations/instance-ai/should-mark-all-nodes-as-success-after-execution-completes/1776326915172-unknown-host-POST-_v1_messages-4d1c93f7.json create mode 100644 packages/testing/playwright/expectations/instance-ai/should-mark-all-nodes-as-success-after-execution-completes/1776326915172-unknown-host-POST-_v1_messages-8a23f6c2.json create mode 100644 packages/testing/playwright/expectations/instance-ai/should-mark-all-nodes-as-success-after-execution-completes/1776326915173-unknown-host-POST-_v1_messages-4d1c93f7.json create mode 100644 packages/testing/playwright/expectations/instance-ai/should-mark-all-nodes-as-success-after-execution-completes/1776326915174-unknown-host-POST-_v1_messages-8a23f6c2.json create mode 100644 packages/testing/playwright/expectations/instance-ai/should-mark-all-nodes-as-success-after-execution-completes/1776326915175-unknown-host-POST-_v1_messages-8a23f6c2.json create mode 100644 packages/testing/playwright/expectations/instance-ai/should-mark-all-nodes-as-success-after-execution-completes/1776326915176-unknown-host-POST-_v1_messages-4d1c93f7.json create mode 100644 packages/testing/playwright/expectations/instance-ai/should-mark-all-nodes-as-success-after-execution-completes/1776326915177-unknown-host-POST-_v1_messages-4d1c93f7.json create mode 100644 packages/testing/playwright/expectations/instance-ai/should-mark-all-nodes-as-success-after-execution-completes/1776326915177-unknown-host-POST-_v1_messages-8a23f6c2.json create mode 100644 packages/testing/playwright/expectations/instance-ai/should-mark-all-nodes-as-success-after-execution-completes/1776326915178-unknown-host-POST-_v1_messages-8a23f6c2.json create mode 100644 packages/testing/playwright/expectations/instance-ai/should-mark-all-nodes-as-success-after-execution-completes/1776326915179-unknown-host-POST-_v1_messages-8a23f6c2.json create mode 100644 packages/testing/playwright/expectations/instance-ai/should-mark-all-nodes-as-success-after-execution-completes/trace.jsonl diff --git a/biome.jsonc b/biome.jsonc index c63bfb56f26..81acefb3271 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -16,7 +16,8 @@ "**/CHANGELOG.md", "**/cl100k_base.json", "**/o200k_base.json", - "**/*.generated.ts" + "**/*.generated.ts", + "**/expectations/**" ] }, "formatter": { diff --git a/packages/@n8n/instance-ai/docs/e2e-tests.md b/packages/@n8n/instance-ai/docs/e2e-tests.md index 1cc4706458c..b4b2172aa3e 100644 --- a/packages/@n8n/instance-ai/docs/e2e-tests.md +++ b/packages/@n8n/instance-ai/docs/e2e-tests.md @@ -253,7 +253,6 @@ Enabled by `E2E_TESTS=true` (set automatically by the Playwright fixture base): | `POST /rest/instance-ai/test/tool-trace` | Load trace events into n8n memory | | `GET /rest/instance-ai/test/tool-trace/:slug` | Retrieve recorded events | | `DELETE /rest/instance-ai/test/tool-trace/:slug` | Clear between tests | -| `POST /rest/instance-ai/test/drain-background-tasks` | Cancel leftover background tasks | ### Page Objects diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-test.controller.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-test.controller.test.ts index b7112aadf09..2b0700f0463 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-test.controller.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-test.controller.test.ts @@ -12,17 +12,21 @@ jest.mock('../eval/execution.service', () => ({ EvalExecutionService: jest.fn(), })); -import type { Request } from 'express'; +import type { WorkflowRepository } from '@n8n/db'; +import type { Request, Response } from 'express'; import { mock } from 'jest-mock-extended'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { InstanceAiTestController } from '../instance-ai-test.controller'; import type { InstanceAiService } from '../instance-ai.service'; +import type { InstanceAiThreadRepository } from '../repositories/instance-ai-thread.repository'; describe('InstanceAiTestController', () => { const instanceAiService = mock(); - const controller = new InstanceAiTestController(instanceAiService); + const threadRepo = mock(); + const workflowRepo = mock(); + const controller = new InstanceAiTestController(instanceAiService, threadRepo, workflowRepo); const originalEnv = process.env; @@ -77,8 +81,9 @@ describe('InstanceAiTestController', () => { const events = [{ kind: 'tool-call' }]; instanceAiService.getTraceEvents.mockReturnValue(events); const req = mock(); + const res = mock(); - const result = controller.getToolTrace(req, 'my-test'); + const result = controller.getToolTrace(req, res, 'my-test'); expect(instanceAiService.getTraceEvents).toHaveBeenCalledWith('my-test'); expect(result).toEqual({ events }); @@ -87,16 +92,18 @@ describe('InstanceAiTestController', () => { it('should throw ForbiddenError when trace replay is not enabled', () => { delete process.env.E2E_TESTS; const req = mock(); + const res = mock(); - expect(() => controller.getToolTrace(req, 'my-test')).toThrow(ForbiddenError); + expect(() => controller.getToolTrace(req, res, 'my-test')).toThrow(ForbiddenError); }); }); describe('clearToolTrace', () => { it('should clear trace events for slug', () => { const req = mock(); + const res = mock(); - const result = controller.clearToolTrace(req, 'my-test'); + const result = controller.clearToolTrace(req, res, 'my-test'); expect(instanceAiService.clearTraceEvents).toHaveBeenCalledWith('my-test'); expect(result).toEqual({ ok: true }); @@ -105,25 +112,33 @@ describe('InstanceAiTestController', () => { it('should throw ForbiddenError when trace replay is not enabled', () => { delete process.env.E2E_TESTS; const req = mock(); + const res = mock(); - expect(() => controller.clearToolTrace(req, 'my-test')).toThrow(ForbiddenError); + expect(() => controller.clearToolTrace(req, res, 'my-test')).toThrow(ForbiddenError); }); }); - describe('drainBackgroundTasks', () => { - it('should cancel all background tasks and return count', () => { - instanceAiService.cancelAllBackgroundTasks.mockReturnValue(3); + describe('reset', () => { + it('should clear per-thread state and delete threads + workflows', async () => { + threadRepo.find.mockResolvedValue([{ id: 't1' }, { id: 't2' }] as never); + workflowRepo.find.mockResolvedValue([{ id: 'w1' }, { id: 'w2' }, { id: 'w3' }] as never); - const result = controller.drainBackgroundTasks(); + const result = await controller.reset(); expect(instanceAiService.cancelAllBackgroundTasks).toHaveBeenCalled(); - expect(result).toEqual({ ok: true, cancelled: 3 }); + expect(instanceAiService.clearThreadState).toHaveBeenCalledWith('t1'); + expect(instanceAiService.clearThreadState).toHaveBeenCalledWith('t2'); + expect(threadRepo.clear).toHaveBeenCalled(); + expect(workflowRepo.delete).toHaveBeenCalledWith('w1'); + expect(workflowRepo.delete).toHaveBeenCalledWith('w2'); + expect(workflowRepo.delete).toHaveBeenCalledWith('w3'); + expect(result).toEqual({ ok: true, threadsDeleted: 2, workflowsDeleted: 3 }); }); - it('should throw ForbiddenError when trace replay is not enabled', () => { + it('should throw ForbiddenError when trace replay is not enabled', async () => { delete process.env.E2E_TESTS; - expect(() => controller.drainBackgroundTasks()).toThrow(ForbiddenError); + await expect(controller.reset()).rejects.toThrow(ForbiddenError); }); }); }); diff --git a/packages/cli/src/modules/instance-ai/instance-ai-test.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai-test.controller.ts index 2ed59243560..d1edc8676a1 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-test.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-test.controller.ts @@ -1,8 +1,10 @@ +import { WorkflowRepository } from '@n8n/db'; import { RestController, Get, Post, Delete, Param } from '@n8n/decorators'; -import type { Request } from 'express'; +import type { Request, Response } from 'express'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { InstanceAiThreadRepository } from './repositories/instance-ai-thread.repository'; import { InstanceAiService } from './instance-ai.service'; /** @@ -11,7 +13,11 @@ import { InstanceAiService } from './instance-ai.service'; */ @RestController('/instance-ai') export class InstanceAiTestController { - constructor(private readonly instanceAiService: InstanceAiService) {} + constructor( + private readonly instanceAiService: InstanceAiService, + private readonly threadRepo: InstanceAiThreadRepository, + private readonly workflowRepo: WorkflowRepository, + ) {} @Post('/test/tool-trace', { skipAuth: true }) loadToolTrace(req: Request) { @@ -26,23 +32,52 @@ export class InstanceAiTestController { } @Get('/test/tool-trace/:slug', { skipAuth: true }) - getToolTrace(_req: Request, @Param('slug') slug: string) { + getToolTrace(_req: Request, _res: Response, @Param('slug') slug: string) { this.assertTraceReplayEnabled(); return { events: this.instanceAiService.getTraceEvents(slug) }; } @Delete('/test/tool-trace/:slug', { skipAuth: true }) - clearToolTrace(_req: Request, @Param('slug') slug: string) { + clearToolTrace(_req: Request, _res: Response, @Param('slug') slug: string) { this.assertTraceReplayEnabled(); this.instanceAiService.clearTraceEvents(slug); return { ok: true }; } - @Post('/test/drain-background-tasks', { skipAuth: true }) - drainBackgroundTasks() { + /** + * Wipe all Instance AI state and user workflows between tests. + * + * Recording pollution vector: the orchestrator's system prompt tells the LLM + * to "list existing workflows/credentials first", so workflows left over from + * a prior test show up in `list-workflows` tool output and leak into the next + * test's recorded responses (observed: a follow-up test's recording referencing + * the previous test's workflow name). + * + * This endpoint cancels background tasks, clears per-thread in-memory state, + * and deletes all thread + workflow rows. + */ + @Post('/test/reset', { skipAuth: true }) + async reset() { this.assertTraceReplayEnabled(); - const cancelled = this.instanceAiService.cancelAllBackgroundTasks(); - return { ok: true, cancelled }; + + this.instanceAiService.cancelAllBackgroundTasks(); + + const threads = await this.threadRepo.find({ select: ['id'] }); + for (const { id } of threads) { + await this.instanceAiService.clearThreadState(id); + } + await this.threadRepo.clear(); + + const workflowIds = await this.workflowRepo.find({ select: ['id'] }); + for (const { id } of workflowIds) { + await this.workflowRepo.delete(id); + } + + return { + ok: true, + threadsDeleted: threads.length, + workflowsDeleted: workflowIds.length, + }; } private assertTraceReplayEnabled(): void { diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowPreview.vue b/packages/frontend/editor-ui/src/app/components/WorkflowPreview.vue index d02599a2285..a9705eb5a4e 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowPreview.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowPreview.vue @@ -246,7 +246,7 @@ watch( }, ); -defineExpose({ iframeRef }); +defineExpose({ iframeRef, reloadExecution: loadExecution });