Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3a52572
feat(editor): pass canExecute flag to iframe and enable push connection
mutdmour Apr 10, 2026
ebe1448
feat(editor): enable execution UI and keyboard shortcut in preview
mutdmour Apr 10, 2026
d4ebbae
feat(editor): enable per-node run buttons in read-only executable mode
mutdmour Apr 10, 2026
712ada0
feat(editor): auto-select NDV output tab for nodes with execution data
mutdmour Apr 10, 2026
06f1c07
feat(editor): handle Chat Trigger and disabled state in preview run b…
mutdmour Apr 10, 2026
09e98c6
fix(editor): Polish instance AI artifact preview tabs (no-changelog)
mutdmour Apr 8, 2026
01d31c2
fix exec finished bug
mutdmour Apr 8, 2026
d92a394
test(editor): Add e2e test for instance AI preview execution state bu…
mutdmour Apr 8, 2026
7d2fd71
fix(editor): Relay all pending execution events before sending execut…
mutdmour Apr 8, 2026
3a086d9
test(editor): Add comprehensive instance AI e2e tests (no-changelog)
mutdmour Apr 9, 2026
24f2b5b
feat: Add trace replay infrastructure for instance AI e2e tests (no-c…
mutdmour Apr 9, 2026
45ab6c6
fix: Use system prompt body matcher for proxy expectations (no-change…
mutdmour Apr 9, 2026
2492af2
fix: Ensure recording mode when real API key is present (no-changelog)
mutdmour Apr 10, 2026
3d40bb1
chore: Re-record proxy expectations with body matchers (no-changelog)
mutdmour Apr 10, 2026
b0c80ff
fix: Set unlimited on last LLM expectation, not last file (no-changelog)
mutdmour Apr 10, 2026
0b3d2f0
fix: Share TraceIndex across follow-up runs in trace replay (no-chang…
mutdmour Apr 10, 2026
465a156
fix: Wait for agent completion in sidebar tests to prevent flakiness …
mutdmour Apr 10, 2026
1395b32
fix: Remove contaminated proxy expectations and drain background task…
mutdmour Apr 10, 2026
553ac57
fix: Retry proxy requests with exponential backoff to handle ECONNRES…
mutdmour Apr 10, 2026
427451d
fix: Use identity-based thread lookups to prevent cross-test contamin…
mutdmour Apr 10, 2026
6a3beee
Merge remote-tracking branch 'origin/feature/instance-ai-tabs' into i…
mutdmour Apr 10, 2026
ad54dff
fix(editor): Ensure iframe push connection works for executable preview
mutdmour Apr 13, 2026
4b0d5d4
test(editor): Add e2e tests for instance AI workflow execution (no-ch…
mutdmour Apr 13, 2026
ddbce8a
test(editor): Remove debugging code from execution test (no-changelog)
mutdmour Apr 13, 2026
1568b60
fix(editor): Don't disable run button on iframe workflow load with ca…
mutdmour Apr 13, 2026
230a321
test(editor): Record LLM expectations for instance AI execution tests…
mutdmour Apr 13, 2026
ae97acf
fix(editor): Hide execute button for chat-trigger-only workflows in p…
mutdmour Apr 14, 2026
cfea919
Merge remote-tracking branch 'origin/master' into instance-ai-run
mutdmour Apr 14, 2026
73b0633
chore: Remove api-staging community nodes recordings (no-changelog)
mutdmour Apr 14, 2026
763b141
chore: Revert master merge changes to instance-ai controller (no-chan…
mutdmour Apr 14, 2026
f28486a
fix(editor): Remove disabledReason tooltip from CanvasRunWorkflowButt…
mutdmour Apr 14, 2026
b0c4ffb
fix(editor): Address cubic PR review comments (no-changelog)
mutdmour Apr 15, 2026
43b7e85
test(core): Add editor-only Docker build and local proxy for faster t…
mutdmour Apr 16, 2026
2c6e163
fix(core): Suppress naming-convention lint errors for ProxyServer imp…
mutdmour Apr 16, 2026
df405d8
cleanup
mutdmour Apr 16, 2026
013e319
fix(editor): Fix prettier formatting in CanvasRunWorkflowButton (no-c…
mutdmour Apr 17, 2026
a291bab
Merge remote-tracking branch 'origin/master' into instance-ai-run
mutdmour Apr 17, 2026
8590ad1
fix(editor): Mock pushConnectionStore in useChatState tests (no-chang…
mutdmour Apr 17, 2026
be731c2
fix(editor): Skip re-execution e2e test in multi-main mode (no-change…
mutdmour Apr 17, 2026
f69f3aa
merge
mutdmour Apr 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"build:n8n": "node scripts/build-n8n.mjs",
"build:deploy": "node scripts/build-n8n.mjs",
"build:docker": "node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs",
"build:docker:editor": "node scripts/build-docker-editor.mjs",
"build:docker:coverage": "BUILD_WITH_COVERAGE=true node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs",
"build:docker:scan": "node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs && node scripts/scan-n8n-image.mjs",
"build:docker:clean": "TURBO_FORCE=true node scripts/build-n8n.mjs && DOCKER_BUILD_NO_CACHE=true DOCKER_BUILD_BASE_IMAGE=true node scripts/dockerize-n8n.mjs",
Expand Down
5 changes: 5 additions & 0 deletions packages/frontend/@n8n/stores/src/useRootStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ export const useRootStore = defineStore(STORES.ROOT, () => {
state.value.binaryDataMode = value;
};

const setPushRef = (value: string) => {
state.value.pushRef = value;
};

// #endregion

return {
Expand Down Expand Up @@ -255,5 +259,6 @@ export const useRootStore = defineStore(STORES.ROOT, () => {
setN8nMetadata,
setDefaultLocale,
setBinaryDataMode,
setPushRef,
};
});
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,47 @@ describe('WorkflowPreview', () => {
});
});

describe('canExecute prop', () => {
it('should include canExecute=true in iframe src when canExecute prop is true', () => {
const { container } = renderComponent({
pinia,
props: {
canExecute: true,
},
});

const iframe = container.querySelector('iframe');
expect(iframe?.getAttribute('src')).toContain('canExecute=true');
});

it('should not include canExecute param when canExecute prop is false', () => {
const { container } = renderComponent({
pinia,
props: {
canExecute: false,
},
});

const iframe = container.querySelector('iframe');
expect(iframe?.getAttribute('src')).not.toContain('canExecute');
});

it('should include both hideControls and canExecute when both are true', () => {
const { container } = renderComponent({
pinia,
props: {
hideControls: true,
canExecute: true,
},
});

const iframe = container.querySelector('iframe');
const src = iframe?.getAttribute('src') ?? '';
expect(src).toContain('hideControls=true');
expect(src).toContain('canExecute=true');
});
});

describe('ready event', () => {
it('should emit ready event when iframe sends n8nReady command', async () => {
const { emitted } = renderComponent({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const props = withDefaults(
focusOnLoad?: boolean;
hideControls?: boolean;
suppressNotifications?: boolean;
canExecute?: boolean;
}>(),
{
loading: false,
Expand All @@ -36,6 +37,7 @@ const props = withDefaults(
focusOnLoad: true,
hideControls: false,
suppressNotifications: false,
canExecute: false,
},
);

Expand All @@ -58,10 +60,15 @@ const scrollY = ref(0);

const iframeSrc = computed(() => {
const basePath = `${window.BASE_PATH ?? '/'}workflows/demo`;
const params = new URLSearchParams();
if (props.hideControls) {
return `${basePath}?hideControls=true`;
params.set('hideControls', 'true');
}
return basePath;
if (props.canExecute) {
params.set('canExecute', 'true');
}
const qs = params.toString();
return qs ? `${basePath}?${qs}` : basePath;
});

const showPreview = computed(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,15 @@ vi.mock('@/features/execution/executions/executions.utils', async (importOrigina
};
});

const mockRoute = vi.hoisted(() => ({
name: 'workflow' as string,
query: {} as Record<string, string>,
}));
vi.mock('vue-router', async (importOriginal) => {
const actual = (await importOriginal()) as object;
return {
...actual,
useRoute: vi.fn(() => ({ name: 'workflow' })),
useRoute: vi.fn(() => mockRoute),
};
});

Expand All @@ -93,6 +97,8 @@ describe('usePostMessageHandler', () => {
vi.clearAllMocks();
setActivePinia(createTestingPinia());
mockIsProductionExecutionPreview.value = false;
mockRoute.name = 'workflow';
mockRoute.query = {};
workflowState = createMockWorkflowState();
});

Expand Down Expand Up @@ -185,6 +191,74 @@ describe('usePostMessageHandler', () => {
cleanup();
});

it('should override workflow id to "demo" in iframe context when canExecute is not set', async () => {
// Simulate iframe context
const originalParent = Object.getOwnPropertyDescriptor(window, 'parent');
Object.defineProperty(window, 'parent', {
value: { postMessage: vi.fn() },
writable: true,
configurable: true,
});

const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();

const workflow = { id: 'real-wf-id', nodes: [], connections: {} };
const messageEvent = new MessageEvent('message', {
data: JSON.stringify({ command: 'openWorkflow', workflow }),
});
window.dispatchEvent(messageEvent);

await vi.waitFor(() => {
expect(mockImportWorkflowExact).toHaveBeenCalledWith(
expect.objectContaining({ workflow: expect.objectContaining({ id: 'demo' }) }),
);
});

cleanup();
if (originalParent) {
Object.defineProperty(window, 'parent', originalParent);
}
});

it('should preserve real workflow id in iframe context when canExecute is true', async () => {
mockRoute.query = { canExecute: 'true' };

// Simulate iframe context
const originalParent = Object.getOwnPropertyDescriptor(window, 'parent');
Object.defineProperty(window, 'parent', {
value: { postMessage: vi.fn() },
writable: true,
configurable: true,
});

const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: shallowRef(null),
});
setup();

const workflow = { id: 'real-wf-id', nodes: [], connections: {} };
const messageEvent = new MessageEvent('message', {
data: JSON.stringify({ command: 'openWorkflow', workflow }),
});
window.dispatchEvent(messageEvent);

await vi.waitFor(() => {
expect(mockImportWorkflowExact).toHaveBeenCalledWith(
expect.objectContaining({ workflow: expect.objectContaining({ id: 'real-wf-id' }) }),
);
});

cleanup();
if (originalParent) {
Object.defineProperty(window, 'parent', originalParent);
}
});

it('should emit tidyUp event when tidyUp is true', async () => {
const { setup, cleanup } = usePostMessageHandler({
workflowState,
Expand Down Expand Up @@ -346,6 +420,54 @@ describe('usePostMessageHandler', () => {
cleanup();
});

it('should always override workflow id to "demo" in iframe context', async () => {
mockRoute.query = { canExecute: 'true' };

// Simulate iframe context
const originalParent = Object.getOwnPropertyDescriptor(window, 'parent');
Object.defineProperty(window, 'parent', {
value: { postMessage: vi.fn() },
writable: true,
configurable: true,
});

const mockSetPinData = vi.fn();
const storeRef = shallowRef({ setPinData: mockSetPinData } as never);

const { setup, cleanup } = usePostMessageHandler({
workflowState,
currentWorkflowDocumentStore: storeRef,
});
setup();

const messageEvent = new MessageEvent('message', {
data: JSON.stringify({
command: 'openExecutionPreview',
workflow: {
id: 'real-wf-id',
nodes: [{ name: 'Node1' }],
connections: {},
},
nodeExecutionSchema: {},
executionStatus: 'success',
}),
});
window.dispatchEvent(messageEvent);

await vi.waitFor(() => {
expect(mockImportWorkflowExact).toHaveBeenCalledWith(
expect.objectContaining({
workflow: expect.objectContaining({ id: 'demo' }),
}),
);
});

cleanup();
if (originalParent) {
Object.defineProperty(window, 'parent', originalParent);
}
});

it('should throw if workflow has no nodes', async () => {
const { setup, cleanup } = usePostMessageHandler({
workflowState,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { nextTick, type ShallowRef } from 'vue';
import { useI18n } from '@n8n/i18n';
import { useRoute } from 'vue-router';
import type { ExecutionStatus, ExecutionSummary } from 'n8n-workflow';
import { useToast } from '@/app/composables/useToast';
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
Expand Down Expand Up @@ -45,6 +46,7 @@ export function usePostMessageHandler({
const telemetry = useTelemetry();
const nodeHelpers = useNodeHelpers();

const route = useRoute();
const workflowsStore = useWorkflowsStore();
const { resetWorkspace, openExecution, fitView } = useCanvasOperations();
const { importWorkflowExact } = useWorkflowImport(currentWorkflowDocumentStore);
Expand Down Expand Up @@ -81,12 +83,24 @@ export function usePostMessageHandler({
if (json.projectId) {
await projectsStore.fetchAndSetProject(json.projectId);
}

// On the demo route, override the workflow ID to 'demo' so the iframe
// doesn't reference a real workflow — unless canExecute is enabled,
// in which case the real ID is needed for execution API calls.
if (window !== window.parent && route.query.canExecute !== 'true') {
json.workflow.id = 'demo';
}

await importWorkflowExact(json);

// importWorkflowExact → resetWorkspace resets activeExecutionId to undefined,
// which causes the iframe to reject push execution events. Re-set to null so
// the iframe stays receptive to incoming execution push events.
if (window !== window.parent) {
// which causes the iframe to reject push execution events relayed from the
// parent. Re-set to null so the iframe stays receptive — but only when
// canExecute is disabled. When canExecute is enabled, leave it as undefined
// so the run button isn't disabled (isWorkflowRunning treats null as
// "execution starting"). The user-triggered execution flow will handle
// activeExecutionId itself.
if (window !== window.parent && route.query.canExecute !== 'true') {
workflowState.setActiveExecutionId(null);
}

Expand Down Expand Up @@ -162,6 +176,12 @@ export function usePostMessageHandler({
throw new Error('Invalid workflow object');
}

// Execution previews always use 'demo' ID — they display pre-computed
// results and never need real execution API calls.
if (window !== window.parent) {
json.workflow.id = 'demo';
}

if (json.projectId) {
await projectsStore.fetchAndSetProject(json.projectId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,16 @@ describe('useWorkflowImport', () => {
);
});

it('should use "demo" id on demo routes', async () => {
it('should pass through workflow id as-is on demo routes', async () => {
mockRoute.name = VIEWS.DEMO;

const storeRef = shallowRef(null);
const { importWorkflowExact } = useWorkflowImport(storeRef);

await importWorkflowExact({ workflow: createWorkflowData({ id: 'ignored-id' }) });
await importWorkflowExact({ workflow: createWorkflowData({ id: 'real-workflow-id' }) });

expect(mockInitializeWorkspace).toHaveBeenCalledWith(expect.objectContaining({ id: 'demo' }));
expect(mockInitializeWorkspace).toHaveBeenCalledWith(
expect.objectContaining({ id: 'real-workflow-id' }),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export function useWorkflowImport(

const { workflowDocumentStore } = await initializeWorkspace({
...workflowData,
id: isDemoRoute.value ? 'demo' : workflowData.id,
nodes: getNodesWithNormalizedPosition<INodeUi>(workflowData.nodes),
} as IWorkflowDb);

Expand Down
Loading
Loading