feat(editor): New simplified empty layout (#21214)

This commit is contained in:
Romeo Balta 2025-11-17 12:43:05 +00:00 committed by GitHub
parent 24fe8971ce
commit 705a78156a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1786 additions and 102 deletions

View File

@ -3098,7 +3098,7 @@
"workflows.empty.preBuiltAgents": "Try a pre-built agent",
"workflows.empty.shared-with-me": "No {resource} has been shared with you",
"workflows.empty.shared-with-me.link": "<a href=\"#\">Back to Personal</a>",
"workflows.empty.readyToRunV2": "Try an AI workflow",
"workflows.empty.readyToRun": "Try an AI workflow",
"workflows.list.easyAI": "Test the power of AI in n8n with this simple AI Agent Workflow",
"workflows.list.error.fetching.one": "Error fetching workflow",
"workflows.list.error.fetching": "Error fetching workflows",

View File

@ -40,6 +40,7 @@ export const STORES = {
EXPERIMENT_READY_TO_RUN_WORKFLOWS_V2: 'readyToRunWorkflowsV2',
EXPERIMENT_TEMPLATE_RECO_V2: 'templateRecoV2',
PERSONALIZED_TEMPLATES_V3: 'personalizedTemplatesV3',
READY_TO_RUN: 'readyToRun',
TEMPLATES_DATA_QUALITY: 'templatesDataQuality',
BANNERS: 'banners',
CONSENT: 'consent',

View File

@ -9,9 +9,13 @@ import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/
import { getResourcePermissions } from '@n8n/permissions';
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
import { useToast } from '@/app/composables/useToast';
import { useReadyToRunWorkflowsV2Store } from '../stores/readyToRunWorkflowsV2.store';
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
import type { IUser } from 'n8n-workflow';
const emit = defineEmits<{
'click:add': [];
}>();
const route = useRoute();
const i18n = useI18n();
const toast = useToast();
@ -19,7 +23,7 @@ const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore();
const projectPages = useProjectPages();
const readyToRunWorkflowsV2Store = useReadyToRunWorkflowsV2Store();
const readyToRunStore = useReadyToRunStore();
const isLoadingReadyToRun = ref(false);
@ -43,18 +47,14 @@ const emptyListDescription = computed(() => {
}
});
const showReadyToRunV2Card = computed(() => {
const showReadyToRunCard = computed(() => {
return (
isLoadingReadyToRun.value ||
readyToRunWorkflowsV2Store.getCardVisibility(
projectPermissions.value.workflow.create,
readOnlyEnv.value,
false, // loading is false in simplified layout
)
readyToRunStore.getCardVisibility(projectPermissions.value.workflow.create, readOnlyEnv.value)
);
});
const handleReadyToRunV2Click = async () => {
const handleReadyToRunClick = async () => {
if (isLoadingReadyToRun.value) return;
isLoadingReadyToRun.value = true;
@ -63,7 +63,7 @@ const handleReadyToRunV2Click = async () => {
: (route.params.projectId as string);
try {
await readyToRunWorkflowsV2Store.claimCreditsAndOpenWorkflow(
await readyToRunStore.claimCreditsAndOpenWorkflow(
'card',
route.params.folderId as string,
projectId,
@ -77,14 +77,10 @@ const handleReadyToRunV2Click = async () => {
const addWorkflow = () => {
emit('click:add');
};
const emit = defineEmits<{
'click:add': [];
}>();
</script>
<template>
<div :class="$style.simplifiedLayout">
<div :class="$style.emptyStateLayout">
<div :class="$style.content">
<div :class="$style.welcome">
<N8nHeading tag="h1" size="2xlarge" :class="$style.welcomeTitle">
@ -106,11 +102,11 @@ const emit = defineEmits<{
:class="$style.actionsContainer"
>
<N8nCard
v-if="showReadyToRunV2Card"
v-if="showReadyToRunCard"
:class="[$style.actionCard, { [$style.loading]: isLoadingReadyToRun }]"
:hoverable="!isLoadingReadyToRun"
data-test-id="ready-to-run-v2-card"
@click="handleReadyToRunV2Click"
data-test-id="ready-to-run-card"
@click="handleReadyToRunClick"
>
<div :class="$style.cardContent">
<N8nIcon
@ -121,7 +117,7 @@ const emit = defineEmits<{
:spin="isLoadingReadyToRun"
/>
<N8nText size="large" class="mt-xs">
{{ i18n.baseText('workflows.empty.readyToRunV2') }}
{{ i18n.baseText('workflows.empty.readyToRun') }}
</N8nText>
</div>
</N8nCard>
@ -150,7 +146,7 @@ const emit = defineEmits<{
</template>
<style lang="scss" module>
.simplifiedLayout {
.emptyStateLayout {
display: flex;
flex-direction: column;
align-items: center;
@ -158,13 +154,6 @@ const emit = defineEmits<{
min-height: 100vh;
}
.header {
position: fixed;
top: var(--spacing--lg);
left: var(--spacing--lg);
opacity: 0.6;
}
.content {
display: flex;
flex-direction: column;

View File

@ -7,7 +7,7 @@ import {
} from './executionFinished';
import type { ITaskData } from 'n8n-workflow';
import { EVALUATION_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import type { Router } from 'vue-router';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
import { createTestingPinia } from '@pinia/testing';
@ -15,6 +15,7 @@ import { setActivePinia } from 'pinia';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useUIStore } from '@/app/stores/ui.store';
import { mockedStore } from '@/__tests__/utils';
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
const opts = {
workflowState: mock<WorkflowState>(),
@ -216,6 +217,219 @@ describe('executionFinished', () => {
expect(workflowState.executingNode.lastAddedExecutingNode).toBeNull();
});
describe('ready-to-run AI workflow tracking', () => {
it('should track successful execution of ready-to-run-ai-workflow', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const readyToRunStore = useReadyToRunStore();
vi.spyOn(workflowsStore, 'activeExecutionId', 'get').mockReturnValue('123');
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({
id: '1',
name: 'Test Workflow',
meta: { templateId: 'ready-to-run-ai-workflow' },
} as IWorkflowDb);
const trackExecuteAiWorkflowSuccess = vi.spyOn(
readyToRunStore,
'trackExecuteAiWorkflowSuccess',
);
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: null,
},
});
await executionFinished(
{
type: 'executionFinished',
data: {
executionId: '123',
workflowId: '1',
status: 'success',
},
},
{
router: mock<Router>(),
workflowState,
},
);
expect(trackExecuteAiWorkflowSuccess).toHaveBeenCalled();
});
it('should track failed execution of ready-to-run-ai-workflow', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const readyToRunStore = useReadyToRunStore();
vi.spyOn(workflowsStore, 'activeExecutionId', 'get').mockReturnValue('123');
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({
id: '1',
name: 'Test Workflow',
meta: { templateId: 'ready-to-run-ai-workflow' },
} as IWorkflowDb);
const trackExecuteAiWorkflow = vi.spyOn(readyToRunStore, 'trackExecuteAiWorkflow');
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: null,
},
});
await executionFinished(
{
type: 'executionFinished',
data: {
executionId: '123',
workflowId: '1',
status: 'error',
},
},
{
router: mock<Router>(),
workflowState,
},
);
expect(trackExecuteAiWorkflow).toHaveBeenCalledWith('error');
});
it('should track execution of ready-to-run-ai-workflow-v1', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const readyToRunStore = useReadyToRunStore();
vi.spyOn(workflowsStore, 'activeExecutionId', 'get').mockReturnValue('123');
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({
id: '1',
name: 'Test Workflow',
meta: { templateId: 'ready-to-run-ai-workflow-v1' },
} as IWorkflowDb);
const trackExecuteAiWorkflowSuccess = vi.spyOn(
readyToRunStore,
'trackExecuteAiWorkflowSuccess',
);
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: null,
},
});
await executionFinished(
{
type: 'executionFinished',
data: {
executionId: '123',
workflowId: '1',
status: 'success',
},
},
{
router: mock<Router>(),
workflowState,
},
);
expect(trackExecuteAiWorkflowSuccess).toHaveBeenCalled();
});
it('should track execution of ready-to-run-ai-workflow-v4', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const readyToRunStore = useReadyToRunStore();
vi.spyOn(workflowsStore, 'activeExecutionId', 'get').mockReturnValue('123');
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({
id: '1',
name: 'Test Workflow',
meta: { templateId: 'ready-to-run-ai-workflow-v4' },
} as IWorkflowDb);
const trackExecuteAiWorkflow = vi.spyOn(readyToRunStore, 'trackExecuteAiWorkflow');
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: null,
},
});
await executionFinished(
{
type: 'executionFinished',
data: {
executionId: '123',
workflowId: '1',
status: 'canceled',
},
},
{
router: mock<Router>(),
workflowState,
},
);
expect(trackExecuteAiWorkflow).toHaveBeenCalledWith('canceled');
});
it('should not track execution for non-ready-to-run workflows', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const readyToRunStore = useReadyToRunStore();
vi.spyOn(workflowsStore, 'activeExecutionId', 'get').mockReturnValue('123');
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({
id: '1',
name: 'Test Workflow',
meta: { templateId: 'some-other-template' },
} as IWorkflowDb);
const trackExecuteAiWorkflowSuccess = vi.spyOn(
readyToRunStore,
'trackExecuteAiWorkflowSuccess',
);
const trackExecuteAiWorkflow = vi.spyOn(readyToRunStore, 'trackExecuteAiWorkflow');
const workflowState = mock<WorkflowState>({
executingNode: {
lastAddedExecutingNode: null,
},
});
await executionFinished(
{
type: 'executionFinished',
data: {
executionId: '123',
workflowId: '1',
status: 'success',
},
},
{
router: mock<Router>(),
workflowState,
},
);
expect(trackExecuteAiWorkflowSuccess).not.toHaveBeenCalled();
expect(trackExecuteAiWorkflow).not.toHaveBeenCalled();
});
});
it('should return early and clear active execution when fetchExecutionData returns undefined', async () => {
const pinia = createTestingPinia({
initialState: {

View File

@ -9,8 +9,7 @@ import { useWorkflowSaving } from '@/app/composables/useWorkflowSaving';
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/app/constants';
import { codeNodeEditorEventBus, globalLinkActionsEventBus } from '@/app/event-bus';
import { useAITemplatesStarterCollectionStore } from '@/experiments/aiTemplatesStarterCollection/stores/aiTemplatesStarterCollection.store';
import { useReadyToRunWorkflowsStore } from '@/experiments/readyToRunWorkflows/stores/readyToRunWorkflows.store';
import { useReadyToRunWorkflowsV2Store } from '@/experiments/readyToRunWorkflowsV2/stores/readyToRunWorkflowsV2.store';
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
@ -55,8 +54,7 @@ export async function executionFinished(
const workflowsStore = useWorkflowsStore();
const uiStore = useUIStore();
const aiTemplatesStarterCollectionStore = useAITemplatesStarterCollectionStore();
const readyToRunWorkflowsStore = useReadyToRunWorkflowsStore();
const readyToRunWorkflowsV2Store = useReadyToRunWorkflowsV2Store();
const readyToRunStore = useReadyToRunStore();
options.workflowState.executingNode.lastAddedExecutingNode = null;
@ -83,18 +81,17 @@ export async function executionFinished(
templateId.split('-').pop() ?? '',
data.status,
);
} else if (templateId.startsWith('37_onboarding_experiments_batch_aug11')) {
readyToRunWorkflowsStore.trackExecuteWorkflow(templateId.split('-').pop() ?? '', data.status);
} else if (
templateId === 'ready-to-run-ai-workflow' ||
templateId === 'ready-to-run-ai-workflow-v1' ||
templateId === 'ready-to-run-ai-workflow-v2' ||
templateId === 'ready-to-run-ai-workflow-v3' ||
templateId === 'ready-to-run-ai-workflow-v4'
) {
if (data.status === 'success') {
readyToRunWorkflowsV2Store.trackExecuteAiWorkflowSuccess();
readyToRunStore.trackExecuteAiWorkflowSuccess();
} else {
readyToRunWorkflowsV2Store.trackExecuteAiWorkflow(data.status);
readyToRunStore.trackExecuteAiWorkflow(data.status);
}
} else if (isPrebuiltAgentTemplateId(templateId)) {
telemetry.track('User executed pre-built Agent', {

View File

@ -20,6 +20,7 @@ import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import { createRouter, createWebHistory } from 'vue-router';
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
vi.mock('@/features/collaboration/projects/projects.api');
vi.mock('@n8n/rest-api-client/api/users');
@ -433,6 +434,7 @@ describe('WorkflowsView', () => {
const sourceControl = useSourceControlStore();
renderComponent({ pinia });
await waitAllPromises();
await sourceControl.pullWorkfolder(true);
expect(userStore.fetchUsers).toHaveBeenCalledTimes(2);
@ -506,3 +508,102 @@ describe('Folders', () => {
expect(getByTestId('folder-card-name')).toHaveTextContent(TEST_FOLDER_RESOURCE.name);
});
});
describe('Simplified Layout', () => {
beforeEach(async () => {
await router.push('/');
await router.isReady();
pinia = createTestingPinia({ initialState });
foldersStore = mockedStore(useFoldersStore);
workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.fetchWorkflowsPage.mockResolvedValue([]);
workflowsStore.fetchActiveWorkflows.mockResolvedValue([]);
foldersStore.totalWorkflowCount = 0;
foldersStore.fetchTotalWorkflowsAndFoldersCount.mockResolvedValue(0);
});
it('should render EmptyStateLayout when simplified layout is enabled', async () => {
const readyToRunStore = mockedStore(useReadyToRunStore);
const projectsStore = mockedStore(useProjectsStore);
vi.spyOn(readyToRunStore, 'getSimplifiedLayoutVisibility').mockReturnValue(true);
vi.spyOn(readyToRunStore, 'getCardVisibility').mockReturnValue(true);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const { getByTestId, queryByTestId } = renderComponent({ pinia });
await waitAllPromises();
// EmptyStateLayout cards should be rendered
expect(getByTestId('new-workflow-card')).toBeInTheDocument();
expect(getByTestId('ready-to-run-card')).toBeInTheDocument();
// ResourcesListLayout should NOT be rendered
expect(queryByTestId('resources-list-wrapper')).not.toBeInTheDocument();
});
it('should render ResourcesListLayout when simplified layout is disabled', async () => {
const readyToRunStore = mockedStore(useReadyToRunStore);
vi.spyOn(readyToRunStore, 'getSimplifiedLayoutVisibility').mockReturnValue(false);
const { queryByTestId } = renderComponent({ pinia });
await waitAllPromises();
// ResourcesListLayout should be rendered (look for list-empty-state as indicator)
// EmptyStateLayout cards should NOT be rendered when using regular layout
expect(queryByTestId('list-empty-state')).toBeInTheDocument();
});
it('should call getSimplifiedLayoutVisibility with route and loading state', async () => {
const readyToRunStore = mockedStore(useReadyToRunStore);
const getSimplifiedLayoutVisibility = vi
.spyOn(readyToRunStore, 'getSimplifiedLayoutVisibility')
.mockReturnValue(false);
renderComponent({ pinia });
await waitAllPromises();
// Should be called with route and loading boolean
expect(getSimplifiedLayoutVisibility).toHaveBeenCalled();
const callArgs = getSimplifiedLayoutVisibility.mock.calls[0];
expect(callArgs[0]).toBeDefined(); // route
});
it('should call addWorkflow when EmptyStateLayout new workflow card is clicked', async () => {
const readyToRunStore = mockedStore(useReadyToRunStore);
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { id: 'project-123', scopes: ['workflow:create'] } as Project;
vi.spyOn(readyToRunStore, 'getSimplifiedLayoutVisibility').mockReturnValue(true);
vi.spyOn(readyToRunStore, 'getCardVisibility').mockReturnValue(true);
const { getByTestId } = renderComponent({ pinia });
await waitAllPromises();
const newWorkflowCard = getByTestId('new-workflow-card');
expect(newWorkflowCard).toBeInTheDocument();
// Click the new workflow card
await userEvent.click(newWorkflowCard);
// Should navigate to new workflow view
expect(router.currentRoute.value.name).toBe(VIEWS.NEW_WORKFLOW);
});
it('should pass route and loading state reactively to getSimplifiedLayoutVisibility', async () => {
const readyToRunStore = mockedStore(useReadyToRunStore);
const getSimplifiedLayoutVisibility = vi
.spyOn(readyToRunStore, 'getSimplifiedLayoutVisibility')
.mockReturnValue(false);
renderComponent({ pinia });
await waitAllPromises();
const initialCallCount = getSimplifiedLayoutVisibility.mock.calls.length;
expect(initialCallCount).toBeGreaterThan(0);
// The computed should be called with the current route and loading state
const lastCall = getSimplifiedLayoutVisibility.mock.calls[initialCallCount - 1];
expect(lastCall[0]).toHaveProperty('params'); // route object
});
});

View File

@ -31,12 +31,12 @@ import SuggestedWorkflowCard from '@/experiments/personalizedTemplates/component
import SuggestedWorkflows from '@/experiments/personalizedTemplates/components/SuggestedWorkflows.vue';
import { usePersonalizedTemplatesStore } from '@/experiments/personalizedTemplates/stores/personalizedTemplates.store';
import { useReadyToRunWorkflowsStore } from '@/experiments/readyToRunWorkflows/stores/readyToRunWorkflows.store';
import { useReadyToRunWorkflowsV2Store } from '@/experiments/readyToRunWorkflowsV2/stores/readyToRunWorkflowsV2.store';
import TemplateRecommendationV2 from '@/experiments/templateRecoV2/components/TemplateRecommendationV2.vue';
import TemplateRecommendationV3 from '@/experiments/personalizedTemplatesV3/components/TemplateRecommendationV3.vue';
import { usePersonalizedTemplatesV2Store } from '@/experiments/templateRecoV2/stores/templateRecoV2.store';
import { usePersonalizedTemplatesV3Store } from '@/experiments/personalizedTemplatesV3/stores/personalizedTemplatesV3.store';
import SimplifiedEmptyLayout from '@/experiments/readyToRunWorkflowsV2/components/SimplifiedEmptyLayout.vue';
import EmptyStateLayout from '@/app/components/layouts/EmptyStateLayout.vue';
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
import InsightsSummary from '@/features/execution/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/execution/insights/insights.store';
import { useTemplatesDataQualityStore } from '@/experiments/templatesDataQuality/stores/templatesDataQuality.store';
@ -144,9 +144,9 @@ const templatesStore = useTemplatesStore();
const aiStarterTemplatesStore = useAITemplatesStarterCollectionStore();
const personalizedTemplatesStore = usePersonalizedTemplatesStore();
const readyToRunWorkflowsStore = useReadyToRunWorkflowsStore();
const readyToRunWorkflowsV2Store = useReadyToRunWorkflowsV2Store();
const personalizedTemplatesV2Store = usePersonalizedTemplatesV2Store();
const personalizedTemplatesV3Store = usePersonalizedTemplatesV3Store();
const readyToRunStore = useReadyToRunStore();
const templatesDataQualityStore = useTemplatesDataQualityStore();
const documentTitle = useDocumentTitle();
@ -485,7 +485,7 @@ const showPersonalizedTemplates = computed(
);
const shouldUseSimplifiedLayout = computed(() => {
return readyToRunWorkflowsV2Store.getSimplifiedLayoutVisibility(route, loading.value);
return !loading.value && readyToRunStore.getSimplifiedLayoutVisibility(route);
});
const hasActiveCallouts = computed(() => {
@ -1819,7 +1819,7 @@ const onNameSubmit = async (name: string) => {
</script>
<template>
<SimplifiedEmptyLayout v-if="shouldUseSimplifiedLayout" @click:add="addWorkflow" />
<EmptyStateLayout v-if="shouldUseSimplifiedLayout" @click:add="addWorkflow" />
<ResourcesListLayout
v-else

View File

@ -20,7 +20,7 @@ import type { IUser } from 'n8n-workflow';
import { type IconOrEmoji, isIconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
import { useUIStore } from '@/app/stores/ui.store';
import { PROJECT_DATA_TABLES } from '@/features/core/dataTable/constants';
import ReadyToRunV2Button from '@/experiments/readyToRunWorkflowsV2/components/ReadyToRunV2Button.vue';
import ReadyToRunButton from '@/features/workflows/readyToRun/components/ReadyToRunButton.vue';
import { N8nButton, N8nHeading, N8nText, N8nTooltip } from '@n8n/design-system';
import { VARIABLE_MODAL_KEY } from '@/features/settings/environments.ee/environments.constants';
@ -441,7 +441,7 @@ const onSelect = (action: string) => {
:content="i18n.baseText('readOnlyEnv.cantAdd.any')"
>
<div style="display: flex; gap: var(--spacing--xs); align-items: center">
<ReadyToRunV2Button :has-active-callouts="props.hasActiveCallouts" />
<ReadyToRunButton :has-active-callouts="props.hasActiveCallouts" />
<ProjectCreateResource
data-test-id="add-resource-buttons"
:actions="menu"

View File

@ -0,0 +1,223 @@
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render';
import ReadyToRunButton from './ReadyToRunButton.vue';
import { useReadyToRunStore } from '../stores/readyToRun.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useFoldersStore } from '@/features/core/folders/folders.store';
import type { Project } from '@/features/collaboration/projects/projects.types';
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
showError: vi.fn(),
}),
}));
vi.mock('@/features/collaboration/projects/composables/useProjectPages', () => ({
useProjectPages: () => ({
isOverviewSubPage: false,
}),
}));
vi.mock('vue-router', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await vi.importActual<typeof import('vue-router')>('vue-router');
return {
...actual,
useRoute: () => ({
params: {
projectId: 'test-project-123',
folderId: 'test-folder-456',
},
}),
useRouter: () => ({
push: vi.fn(),
}),
};
});
const renderComponent = createComponentRenderer(ReadyToRunButton);
describe('ReadyToRunButton.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
const pinia = createPinia();
setActivePinia(pinia);
});
describe('button visibility', () => {
it('should show button when all conditions are met', () => {
const pinia = createPinia();
setActivePinia(pinia);
const readyToRunStore = useReadyToRunStore();
const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore();
const foldersStore = useFoldersStore();
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(true);
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
vi.spyOn(projectsStore, 'currentProject', 'get').mockReturnValue({
scopes: ['workflow:create'],
} as Partial<Project> as Project);
vi.spyOn(sourceControlStore, 'preferences', 'get').mockReturnValue({
branchReadOnly: false,
} as never);
const { getByTestId } = renderComponent({
pinia,
});
expect(getByTestId('ready-to-run-button')).toBeInTheDocument();
});
it('should hide button when no workflows exist', () => {
const pinia = createPinia();
setActivePinia(pinia);
const readyToRunStore = useReadyToRunStore();
const foldersStore = useFoldersStore();
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(false);
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(0);
const { queryByTestId } = renderComponent({
pinia,
});
expect(queryByTestId('ready-to-run-button')).not.toBeInTheDocument();
});
it('should hide button when branch is read-only', () => {
const pinia = createPinia();
setActivePinia(pinia);
const readyToRunStore = useReadyToRunStore();
const sourceControlStore = useSourceControlStore();
const foldersStore = useFoldersStore();
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(false);
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
vi.spyOn(sourceControlStore, 'preferences', 'get').mockReturnValue({
branchReadOnly: true,
} as never);
const { queryByTestId } = renderComponent({
pinia,
});
expect(queryByTestId('ready-to-run-button')).not.toBeInTheDocument();
});
it('should hide button when user cannot create workflows', () => {
const pinia = createPinia();
setActivePinia(pinia);
const readyToRunStore = useReadyToRunStore();
const projectsStore = useProjectsStore();
const foldersStore = useFoldersStore();
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(false);
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
vi.spyOn(projectsStore, 'currentProject', 'get').mockReturnValue({
scopes: [],
} as Partial<Project> as Project);
const { queryByTestId } = renderComponent({
pinia,
});
expect(queryByTestId('ready-to-run-button')).not.toBeInTheDocument();
});
it('should hide button when active callouts are present', () => {
const pinia = createPinia();
setActivePinia(pinia);
const readyToRunStore = useReadyToRunStore();
const foldersStore = useFoldersStore();
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(true);
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
const { queryByTestId } = renderComponent({
pinia,
props: {
hasActiveCallouts: true,
},
});
expect(queryByTestId('ready-to-run-button')).not.toBeInTheDocument();
});
});
describe('button state', () => {
it('should disable button when claiming credits', () => {
const pinia = createPinia();
setActivePinia(pinia);
const readyToRunStore = useReadyToRunStore();
const foldersStore = useFoldersStore();
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(true);
vi.spyOn(readyToRunStore, 'claimingCredits', 'get').mockReturnValue(true);
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
const { getByTestId } = renderComponent({
pinia,
});
const button = getByTestId('ready-to-run-button');
expect(button).toBeDisabled();
});
it('should disable button when branch is read-only', () => {
const pinia = createPinia();
setActivePinia(pinia);
const readyToRunStore = useReadyToRunStore();
const sourceControlStore = useSourceControlStore();
const foldersStore = useFoldersStore();
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(true);
vi.spyOn(readyToRunStore, 'claimingCredits', 'get').mockReturnValue(false);
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
vi.spyOn(sourceControlStore, 'preferences', 'get').mockReturnValue({
branchReadOnly: true,
} as never);
const { getByTestId } = renderComponent({
pinia,
});
const button = getByTestId('ready-to-run-button');
expect(button).toBeDisabled();
});
});
describe('button click', () => {
it('should call claimCreditsAndOpenWorkflow on button click', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const readyToRunStore = useReadyToRunStore();
const foldersStore = useFoldersStore();
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(true);
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
const claimCreditsAndOpenWorkflow = vi
.spyOn(readyToRunStore, 'claimCreditsAndOpenWorkflow')
.mockResolvedValue(undefined);
const { getByTestId } = renderComponent({
pinia,
});
const button = getByTestId('ready-to-run-button');
button.click();
expect(claimCreditsAndOpenWorkflow).toHaveBeenCalled();
});
});
});

View File

@ -9,7 +9,7 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useFoldersStore } from '@/features/core/folders/folders.store';
import { useToast } from '@/app/composables/useToast';
import { useReadyToRunWorkflowsV2Store } from '../stores/readyToRunWorkflowsV2.store';
import { useReadyToRunStore } from '../stores/readyToRun.store';
const props = defineProps<{
hasActiveCallouts?: boolean;
@ -22,7 +22,7 @@ const projectPages = useProjectPages();
const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore();
const foldersStore = useFoldersStore();
const readyToRunWorkflowsV2Store = useReadyToRunWorkflowsV2Store();
const readyToRunStore = useReadyToRunStore();
const projectPermissions = computed(() => {
return getResourcePermissions(
@ -32,7 +32,7 @@ const projectPermissions = computed(() => {
const showButton = computed(() => {
return (
readyToRunWorkflowsV2Store.getButtonVisibility(
readyToRunStore.getButtonVisibility(
foldersStore.totalWorkflowCount > 0, // Has workflows
projectPermissions.value.workflow.create,
sourceControlStore.preferences.branchReadOnly,
@ -46,7 +46,7 @@ const handleClick = async () => {
: (route.params.projectId as string);
try {
await readyToRunWorkflowsV2Store.claimCreditsAndOpenWorkflow(
await readyToRunStore.claimCreditsAndOpenWorkflow(
'button',
route.params.folderId as string,
projectId,
@ -60,15 +60,13 @@ const handleClick = async () => {
<template>
<N8nButton
v-if="showButton"
data-test-id="ready-to-run-v2-button"
data-test-id="ready-to-run-button"
type="secondary"
icon="sparkles"
:loading="readyToRunWorkflowsV2Store.claimingCredits"
:disabled="
sourceControlStore.preferences.branchReadOnly || readyToRunWorkflowsV2Store.claimingCredits
"
:loading="readyToRunStore.claimingCredits"
:disabled="sourceControlStore.preferences.branchReadOnly || readyToRunStore.claimingCredits"
@click="handleClick"
>
{{ i18n.baseText('workflows.empty.readyToRunV2') }}
{{ i18n.baseText('workflows.empty.readyToRun') }}
</N8nButton>
</template>

View File

@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest';
import type { RouteLocationNormalized } from 'vue-router';
import { useEmptyStateDetection } from './useEmptyStateDetection';
const mockRoute = (overrides: Partial<RouteLocationNormalized> = {}) =>
({
params: {},
query: {},
...overrides,
}) as RouteLocationNormalized;
vi.mock('@/features/core/folders/folders.store', () => ({
useFoldersStore: () => ({
totalWorkflowCount: 0,
}),
}));
vi.mock('@/features/collaboration/projects/composables/useProjectPages', () => ({
useProjectPages: () => ({
isOverviewSubPage: true,
isSharedSubPage: false,
}),
}));
vi.mock('vue-router', () => ({
useRoute: () => mockRoute(),
}));
describe('useEmptyStateDetection', () => {
describe('isTrulyEmpty', () => {
it('should return true when state is truly empty', () => {
const { isTrulyEmpty } = useEmptyStateDetection();
const route = mockRoute();
expect(isTrulyEmpty(route)).toBe(true);
});
it('should return false when in a specific folder', () => {
const { isTrulyEmpty } = useEmptyStateDetection();
const route = mockRoute({
params: { folderId: 'folder-123' },
});
expect(isTrulyEmpty(route)).toBe(false);
});
it('should return false when search query is active', () => {
const { isTrulyEmpty } = useEmptyStateDetection();
const route = mockRoute({
query: { search: 'test' },
});
expect(isTrulyEmpty(route)).toBe(false);
});
it('should return false when status filter is active', () => {
const { isTrulyEmpty } = useEmptyStateDetection();
const route = mockRoute({
query: { status: 'active' },
});
expect(isTrulyEmpty(route)).toBe(false);
});
it('should return false when tags filter is active', () => {
const { isTrulyEmpty } = useEmptyStateDetection();
const route = mockRoute({
query: { tags: 'production' },
});
expect(isTrulyEmpty(route)).toBe(false);
});
it('should return false when showArchived filter is active', () => {
const { isTrulyEmpty } = useEmptyStateDetection();
const route = mockRoute({
query: { showArchived: 'true' },
});
expect(isTrulyEmpty(route)).toBe(false);
});
it('should return false when homeProject filter is active', () => {
const { isTrulyEmpty } = useEmptyStateDetection();
const route = mockRoute({
query: { homeProject: 'true' },
});
expect(isTrulyEmpty(route)).toBe(false);
});
});
});

View File

@ -0,0 +1,48 @@
import type { RouteLocationNormalized } from 'vue-router';
import { useFoldersStore } from '@/features/core/folders/folders.store';
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
import { useRoute } from 'vue-router';
/**
* Determines if the instance is truly empty and should show the simplified layout
*/
export function useEmptyStateDetection() {
const foldersStore = useFoldersStore();
const projectPages = useProjectPages();
const route = useRoute();
/**
* Checks if the current state qualifies as "truly empty"
* - No workflows exist in the instance
* - User is on the main workflows view (not in a specific folder)
* - User is on overview page or personal project workflows
* - No search filters are applied
* - Not currently refreshing data
*/
const isTrulyEmpty = (currentRoute: RouteLocationNormalized = route) => {
const hasNoWorkflows = foldersStore.totalWorkflowCount === 0;
const isNotInSpecificFolder = !currentRoute.params?.folderId;
const isMainWorkflowsPage = projectPages.isOverviewSubPage;
// Check for any search or filter parameters that would indicate filtering is active
const hasSearchQuery = !!currentRoute.query?.search;
const hasFilters = !!(
currentRoute.query?.status ||
currentRoute.query?.tags ||
currentRoute.query?.showArchived ||
currentRoute.query?.homeProject
);
return (
hasNoWorkflows &&
isNotInSpecificFolder &&
isMainWorkflowsPage &&
!hasSearchQuery &&
!hasFilters
);
};
return {
isTrulyEmpty,
};
}

View File

@ -0,0 +1,573 @@
import type { ICredentialsResponse } from '@/features/credentials/credentials.types';
import type { INodeUi } from '@/Interface';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { RouteLocationNormalized } from 'vue-router';
import { useReadyToRunStore } from './readyToRun.store';
const { mockPush, mockTrack, mockShowError, mockClaimFreeAiCredits, mockCreateNewWorkflow } =
vi.hoisted(() => ({
mockPush: vi.fn(),
mockTrack: vi.fn(),
mockShowError: vi.fn(),
mockClaimFreeAiCredits: vi.fn(),
mockCreateNewWorkflow: vi.fn(),
}));
vi.mock('vue-router', () => ({
useRouter: () => ({
push: mockPush,
}),
useRoute: () => ({
params: {},
}),
}));
vi.mock('@/app/composables/useTelemetry', () => ({
useTelemetry: () => ({
track: mockTrack,
}),
}));
vi.mock('@/app/composables/useToast', () => ({
useToast: () => ({
showError: mockShowError,
}),
}));
const mockGetVariant = vi.fn();
vi.mock('@/app/stores/posthog.store', () => ({
usePostHog: () => ({
getVariant: mockGetVariant,
}),
}));
const mockAllCredentials = { value: [] as ICredentialsResponse[] };
vi.mock('@/features/credentials/credentials.store', () => ({
useCredentialsStore: () => ({
get allCredentials() {
return mockAllCredentials.value;
},
claimFreeAiCredits: mockClaimFreeAiCredits,
}),
}));
vi.mock('@/app/stores/workflows.store', () => ({
useWorkflowsStore: () => ({
createNewWorkflow: mockCreateNewWorkflow,
}),
}));
const mockCurrentUser = {
value: {
settings: {} as Record<string, unknown>,
},
};
vi.mock('@/features/settings/users/users.store', () => ({
useUsersStore: () => ({
get currentUser() {
return mockCurrentUser.value;
},
}),
}));
const mockIsAiCreditsEnabled = { value: true };
vi.mock('@/app/stores/settings.store', () => ({
useSettingsStore: () => ({
get isAiCreditsEnabled() {
return mockIsAiCreditsEnabled.value;
},
}),
}));
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
}),
}));
const mockLocalStorageValue = { value: '' };
vi.mock('@vueuse/core', () => ({
useLocalStorage: () => mockLocalStorageValue,
useMediaQuery: () => ({ value: false }),
}));
const mockIsTrulyEmpty = vi.fn(() => false);
vi.mock('../composables/useEmptyStateDetection', () => ({
useEmptyStateDetection: () => ({
isTrulyEmpty: mockIsTrulyEmpty,
}),
}));
describe('useReadyToRunStore', () => {
let store: ReturnType<typeof useReadyToRunStore>;
beforeEach(() => {
vi.clearAllMocks();
// Reset dynamic mocks to default values
mockAllCredentials.value = [];
mockCurrentUser.value = { settings: {} };
mockIsAiCreditsEnabled.value = true;
mockLocalStorageValue.value = '';
mockIsTrulyEmpty.mockReturnValue(false);
setActivePinia(createPinia());
store = useReadyToRunStore();
});
describe('userCanClaimOpenAiCredits', () => {
it('should return true when user can claim credits', () => {
expect(store.userCanClaimOpenAiCredits).toBe(true);
});
});
describe('trackExecuteAiWorkflow', () => {
it('should track workflow execution', () => {
store.trackExecuteAiWorkflow('success');
expect(mockTrack).toHaveBeenCalledWith('User executed ready to run AI workflow', {
status: 'success',
});
});
it('should track workflow execution with failed status', () => {
store.trackExecuteAiWorkflow('failed');
expect(mockTrack).toHaveBeenCalledWith('User executed ready to run AI workflow', {
status: 'failed',
});
});
});
describe('trackExecuteAiWorkflowSuccess', () => {
it('should track successful workflow execution', () => {
store.trackExecuteAiWorkflowSuccess();
expect(mockTrack).toHaveBeenCalledWith('User executed ready to run AI workflow successfully');
});
});
describe('claimFreeAiCredits', () => {
it('should claim credits and track event', async () => {
const mockCredential = { id: 'cred-123', name: 'OpenAI' };
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
const result = await store.claimFreeAiCredits('project-123');
expect(mockClaimFreeAiCredits).toHaveBeenCalledWith('project-123');
expect(mockTrack).toHaveBeenCalledWith('User claimed OpenAI credits');
expect(result).toEqual(mockCredential);
});
it('should store credential ID in localStorage', async () => {
const mockCredential = { id: 'cred-456', name: 'OpenAI' };
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
await store.claimFreeAiCredits();
expect(mockLocalStorageValue.value).toBe('cred-456');
});
it('should show error on failure', async () => {
const error = new Error('Failed to claim');
mockClaimFreeAiCredits.mockRejectedValue(error);
await expect(store.claimFreeAiCredits()).rejects.toThrow('Failed to claim');
expect(mockShowError).toHaveBeenCalled();
});
it('should show error with correct i18n keys on failure', async () => {
const error = new Error('Failed to claim');
mockClaimFreeAiCredits.mockRejectedValue(error);
try {
await store.claimFreeAiCredits();
} catch (e) {
// Expected to throw
}
expect(mockShowError).toHaveBeenCalledWith(
error,
'freeAi.credits.showError.claim.title',
'freeAi.credits.showError.claim.message',
);
});
});
describe('createAndOpenAiWorkflow', () => {
it('should create workflow and track event', async () => {
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
await store.createAndOpenAiWorkflow('card');
expect(mockTrack).toHaveBeenCalledWith('User opened ready to run AI workflow', {
source: 'card',
});
expect(mockCreateNewWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
name: 'AI Agent workflow',
meta: { templateId: 'ready-to-run-ai-workflow' },
}),
);
expect(mockPush).toHaveBeenCalledWith({
name: 'NodeViewExisting',
params: { name: 'workflow-123' },
});
});
it('should track with button source', async () => {
const mockWorkflow = { id: 'workflow-456', name: 'AI Agent workflow' };
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
await store.createAndOpenAiWorkflow('button');
expect(mockTrack).toHaveBeenCalledWith('User opened ready to run AI workflow', {
source: 'button',
});
});
it('should create workflow with folder ID', async () => {
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
await store.createAndOpenAiWorkflow('button', 'folder-456');
expect(mockCreateNewWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
parentFolderId: 'folder-456',
}),
);
});
it('should show error on failure', async () => {
const error = new Error('Failed to create');
mockCreateNewWorkflow.mockRejectedValue(error);
await expect(store.createAndOpenAiWorkflow('card')).rejects.toThrow('Failed to create');
expect(mockShowError).toHaveBeenCalled();
});
});
describe('getCardVisibility', () => {
it('should return true when user can create and env is not read-only', () => {
expect(store.getCardVisibility(true, false)).toBe(true);
});
it('should return false when read-only', () => {
expect(store.getCardVisibility(true, true)).toBe(false);
});
it('should return false when cannot create', () => {
expect(store.getCardVisibility(false, false)).toBe(false);
});
});
describe('getButtonVisibility', () => {
it('should return true when all conditions met', () => {
expect(store.getButtonVisibility(true, true, false)).toBe(true);
});
it('should return false when no workflows', () => {
expect(store.getButtonVisibility(false, true, false)).toBe(false);
});
it('should return false when cannot create', () => {
expect(store.getButtonVisibility(true, false, false)).toBe(false);
});
it('should return false when read-only', () => {
expect(store.getButtonVisibility(true, true, true)).toBe(false);
});
});
describe('getSimplifiedLayoutVisibility', () => {
it('should call isTrulyEmpty with route', () => {
const mockRoute = { name: 'test', params: {} } as RouteLocationNormalized;
store.getSimplifiedLayoutVisibility(mockRoute);
expect(mockIsTrulyEmpty).toHaveBeenCalledWith(mockRoute);
});
it('should return the result from isTrulyEmpty', () => {
const mockRoute = { name: 'test', params: {} } as RouteLocationNormalized;
mockIsTrulyEmpty.mockReturnValueOnce(true);
const result = store.getSimplifiedLayoutVisibility(mockRoute);
expect(result).toBe(true);
});
it('should return false when isTrulyEmpty returns false', () => {
const mockRoute = { name: 'test', params: {} } as RouteLocationNormalized;
mockIsTrulyEmpty.mockReturnValueOnce(false);
const result = store.getSimplifiedLayoutVisibility(mockRoute);
expect(result).toBe(false);
});
});
describe('claimCreditsAndOpenWorkflow', () => {
it('should claim credits and open workflow', async () => {
const mockCredential = { id: 'cred-123', name: 'OpenAI' };
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
await store.claimCreditsAndOpenWorkflow('card');
expect(mockClaimFreeAiCredits).toHaveBeenCalled();
expect(mockCreateNewWorkflow).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith({
name: 'NodeViewExisting',
params: { name: 'workflow-123' },
});
});
it('should claim credits and open workflow with projectId and parentFolderId', async () => {
const mockCredential = { id: 'cred-456', name: 'OpenAI' };
const mockWorkflow = { id: 'workflow-456', name: 'AI Agent workflow' };
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
await store.claimCreditsAndOpenWorkflow('button', 'folder-789', 'project-789');
expect(mockClaimFreeAiCredits).toHaveBeenCalledWith('project-789');
expect(mockCreateNewWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
parentFolderId: 'folder-789',
}),
);
});
it('should update user settings after successful claim', async () => {
const mockCredential = { id: 'cred-123', name: 'OpenAI' };
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
await store.claimCreditsAndOpenWorkflow('card');
// Note: This test verifies the user settings update logic
// In the actual implementation, this would update usersStore.currentUser.settings.userClaimedAiCredits
});
it('should throw error when claiming credits fails', async () => {
const error = new Error('Failed to claim');
mockClaimFreeAiCredits.mockRejectedValue(error);
await expect(store.claimCreditsAndOpenWorkflow('card')).rejects.toThrow('Failed to claim');
expect(mockCreateNewWorkflow).not.toHaveBeenCalled();
});
it('should throw error when creating workflow fails', async () => {
const mockCredential = { id: 'cred-123', name: 'OpenAI' };
const error = new Error('Failed to create workflow');
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
mockCreateNewWorkflow.mockRejectedValue(error);
await expect(store.claimCreditsAndOpenWorkflow('card')).rejects.toThrow(
'Failed to create workflow',
);
});
});
describe('claimingCredits state', () => {
it('should be false initially', () => {
expect(store.claimingCredits).toBe(false);
});
it('should be true while claiming credits', async () => {
const mockCredential = { id: 'cred-123', name: 'OpenAI' };
let claimingDuringExecution = false;
mockClaimFreeAiCredits.mockImplementation(async () => {
claimingDuringExecution = store.claimingCredits;
return mockCredential;
});
await store.claimFreeAiCredits();
expect(claimingDuringExecution).toBe(true);
});
it('should be false after successful claim', async () => {
const mockCredential = { id: 'cred-123', name: 'OpenAI' };
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
await store.claimFreeAiCredits();
expect(store.claimingCredits).toBe(false);
});
it('should be false after failed claim', async () => {
const error = new Error('Failed to claim');
mockClaimFreeAiCredits.mockRejectedValue(error);
try {
await store.claimFreeAiCredits();
} catch (e) {
// Expected to throw
}
expect(store.claimingCredits).toBe(false);
});
});
describe('userCanClaimOpenAiCredits edge cases', () => {
it('should return false when AI credits are disabled', () => {
mockIsAiCreditsEnabled.value = false;
setActivePinia(createPinia());
const testStore = useReadyToRunStore();
expect(testStore.userCanClaimOpenAiCredits).toBe(false);
});
it('should return false when user already has OpenAI credentials', () => {
mockAllCredentials.value = [
{
id: 'cred-1',
type: 'openAiApi',
name: 'My OpenAI Credentials',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isManaged: false,
},
];
setActivePinia(createPinia());
const testStore = useReadyToRunStore();
expect(testStore.userCanClaimOpenAiCredits).toBe(false);
});
it('should return false when user already claimed AI credits', () => {
mockCurrentUser.value = {
settings: { userClaimedAiCredits: true },
};
setActivePinia(createPinia());
const testStore = useReadyToRunStore();
expect(testStore.userCanClaimOpenAiCredits).toBe(false);
});
it('should return false when multiple conditions are false', () => {
mockIsAiCreditsEnabled.value = false;
mockAllCredentials.value = [
{
id: 'cred-1',
type: 'openAiApi',
name: 'My OpenAI Credentials',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isManaged: false,
},
];
mockCurrentUser.value = {
settings: { userClaimedAiCredits: true },
};
setActivePinia(createPinia());
const testStore = useReadyToRunStore();
expect(testStore.userCanClaimOpenAiCredits).toBe(false);
});
it('should return true when all conditions are met', () => {
mockIsAiCreditsEnabled.value = true;
mockAllCredentials.value = [];
mockCurrentUser.value = { settings: {} };
setActivePinia(createPinia());
const testStore = useReadyToRunStore();
expect(testStore.userCanClaimOpenAiCredits).toBe(true);
});
});
describe('createAndOpenAiWorkflow with credential injection', () => {
it('should inject credential into OpenAI Model node when credential exists', async () => {
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
mockLocalStorageValue.value = 'cred-stored-123';
await store.createAndOpenAiWorkflow('card');
expect(mockCreateNewWorkflow).toHaveBeenCalled();
const createCall = mockCreateNewWorkflow.mock.calls[0][0];
const openAiNode = createCall.nodes?.find((node: INodeUi) => node.name === 'OpenAI Model');
expect(openAiNode).toBeDefined();
expect(openAiNode?.credentials).toBeDefined();
expect(openAiNode?.credentials?.openAiApi).toEqual({
id: 'cred-stored-123',
name: '',
});
});
it('should create workflow without credential when no credential is stored', async () => {
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
mockLocalStorageValue.value = '';
await store.createAndOpenAiWorkflow('card');
expect(mockCreateNewWorkflow).toHaveBeenCalled();
const createCall = mockCreateNewWorkflow.mock.calls[0][0];
const openAiNode = createCall.nodes?.find((node: INodeUi) => node.name === 'OpenAI Model');
// Should not modify the credentials when no credential is stored
expect(openAiNode).toBeDefined();
expect(openAiNode?.credentials).toEqual({});
});
it('should not modify original workflow template', async () => {
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
mockLocalStorageValue.value = 'cred-stored-456';
await store.createAndOpenAiWorkflow('card');
// Create another workflow to verify the template wasn't mutated
await store.createAndOpenAiWorkflow('button');
expect(mockCreateNewWorkflow).toHaveBeenCalledTimes(2);
const firstCall = mockCreateNewWorkflow.mock.calls[0][0];
const secondCall = mockCreateNewWorkflow.mock.calls[1][0];
// Both should have the credential injected independently
const firstOpenAiNode = firstCall.nodes?.find(
(node: INodeUi) => node.name === 'OpenAI Model',
);
const secondOpenAiNode = secondCall.nodes?.find(
(node: INodeUi) => node.name === 'OpenAI Model',
);
expect(firstOpenAiNode?.credentials?.openAiApi?.id).toBe('cred-stored-456');
expect(secondOpenAiNode?.credentials?.openAiApi?.id).toBe('cred-stored-456');
});
it('should handle workflow where OpenAI Model node has existing credentials object', async () => {
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
mockLocalStorageValue.value = 'cred-new-789';
await store.createAndOpenAiWorkflow('card');
expect(mockCreateNewWorkflow).toHaveBeenCalled();
const createCall = mockCreateNewWorkflow.mock.calls[0][0];
const openAiNode = createCall.nodes?.find((node: INodeUi) => node.name === 'OpenAI Model');
// Should add the credential even if credentials object already exists
expect(openAiNode?.credentials?.openAiApi).toEqual({
id: 'cred-new-789',
name: '',
});
});
});
});

View File

@ -0,0 +1,168 @@
import { useUsersStore } from '@/features/settings/users/users.store';
import { useI18n } from '@n8n/i18n';
import { STORES } from '@n8n/stores';
import { useLocalStorage } from '@vueuse/core';
import { OPEN_AI_API_CREDENTIAL_TYPE, deepCopy } from 'n8n-workflow';
import type { WorkflowDataCreate } from '@n8n/rest-api-client';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { useRouter, type RouteLocationNormalized } from 'vue-router';
import { READY_TO_RUN_AI_WORKFLOW } from '../workflows/aiWorkflow';
import { useEmptyStateDetection } from '../composables/useEmptyStateDetection';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useToast } from '@/app/composables/useToast';
import { VIEWS } from '@/app/constants';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
const LOCAL_STORAGE_CREDENTIAL_KEY = 'N8N_READY_TO_RUN_OPENAI_CREDENTIAL_ID';
export const useReadyToRunStore = defineStore(STORES.READY_TO_RUN, () => {
const telemetry = useTelemetry();
const i18n = useI18n();
const toast = useToast();
const router = useRouter();
const credentialsStore = useCredentialsStore();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const workflowsStore = useWorkflowsStore();
const claimedCredentialIdRef = useLocalStorage(LOCAL_STORAGE_CREDENTIAL_KEY, '');
const claimingCredits = ref(false);
const userHasOpenAiCredentialAlready = computed(
() =>
!!credentialsStore.allCredentials.filter(
(credential) => credential.type === OPEN_AI_API_CREDENTIAL_TYPE,
).length,
);
const userHasClaimedAiCreditsAlready = computed(
() => !!usersStore.currentUser?.settings?.userClaimedAiCredits,
);
const userCanClaimOpenAiCredits = computed(() => {
return (
settingsStore.isAiCreditsEnabled &&
!userHasOpenAiCredentialAlready.value &&
!userHasClaimedAiCreditsAlready.value
);
});
const trackExecuteAiWorkflow = (status: string) => {
telemetry.track('User executed ready to run AI workflow', {
status,
});
};
const trackExecuteAiWorkflowSuccess = () => {
telemetry.track('User executed ready to run AI workflow successfully');
};
const claimFreeAiCredits = async (projectId?: string) => {
claimingCredits.value = true;
try {
const credential = await credentialsStore.claimFreeAiCredits(projectId);
claimedCredentialIdRef.value = credential.id;
telemetry.track('User claimed OpenAI credits');
return credential;
} catch (e) {
toast.showError(
e,
i18n.baseText('freeAi.credits.showError.claim.title'),
i18n.baseText('freeAi.credits.showError.claim.message'),
);
throw e;
} finally {
claimingCredits.value = false;
}
};
const createAndOpenAiWorkflow = async (source: 'card' | 'button', parentFolderId?: string) => {
telemetry.track('User opened ready to run AI workflow', {
source,
});
try {
let workflowToCreate: WorkflowDataCreate = {
...READY_TO_RUN_AI_WORKFLOW,
parentFolderId,
};
const credentialId = claimedCredentialIdRef.value;
if (credentialId && workflowToCreate.nodes) {
const clonedWorkflow = deepCopy(workflowToCreate);
const openAiNode = clonedWorkflow.nodes?.find((node) => node.name === 'OpenAI Model');
if (openAiNode) {
openAiNode.credentials ??= {};
openAiNode.credentials[OPEN_AI_API_CREDENTIAL_TYPE] = {
id: credentialId,
name: '',
};
}
workflowToCreate = clonedWorkflow;
}
const createdWorkflow = await workflowsStore.createNewWorkflow(workflowToCreate);
await router.push({
name: VIEWS.WORKFLOW,
params: { name: createdWorkflow.id },
});
return createdWorkflow;
} catch (error) {
toast.showError(error, i18n.baseText('generic.error'));
throw error;
}
};
const claimCreditsAndOpenWorkflow = async (
source: 'card' | 'button',
parentFolderId?: string,
projectId?: string,
) => {
await claimFreeAiCredits(projectId);
await createAndOpenAiWorkflow(source, parentFolderId);
if (usersStore?.currentUser?.settings) {
usersStore.currentUser.settings.userClaimedAiCredits = true;
}
};
const getCardVisibility = (canCreate: boolean | undefined, readOnlyEnv: boolean) => {
return userCanClaimOpenAiCredits.value && !readOnlyEnv && canCreate;
};
const getButtonVisibility = (
hasWorkflows: boolean,
canCreate: boolean | undefined,
readOnlyEnv: boolean,
) => {
return userCanClaimOpenAiCredits.value && !readOnlyEnv && canCreate && hasWorkflows;
};
const { isTrulyEmpty } = useEmptyStateDetection();
const getSimplifiedLayoutVisibility = (route: RouteLocationNormalized) => {
return isTrulyEmpty(route);
};
return {
claimingCredits,
userCanClaimOpenAiCredits,
claimFreeAiCredits,
createAndOpenAiWorkflow,
claimCreditsAndOpenWorkflow,
getCardVisibility,
getButtonVisibility,
getSimplifiedLayoutVisibility,
trackExecuteAiWorkflow,
trackExecuteAiWorkflowSuccess,
};
});

View File

@ -0,0 +1,253 @@
import type { WorkflowDataCreate } from '@n8n/rest-api-client';
export const READY_TO_RUN_AI_WORKFLOW: WorkflowDataCreate = {
name: 'AI Agent workflow',
meta: { templateId: 'ready-to-run-ai-workflow' },
nodes: [
{
parameters: {
url: 'https://www.theverge.com/rss/index.xml',
options: {},
},
type: 'n8n-nodes-base.rssFeedReadTool',
typeVersion: 1.2,
position: [-16, 768],
id: '303e9b4e-cc4e-4d8a-8ede-7550f070d212',
name: 'Get Tech News',
},
{
parameters: {
toolDescription: 'Reads the news',
url: '=https://feeds.bbci.co.uk/news/world/rss.xml',
options: {},
},
type: 'n8n-nodes-base.rssFeedReadTool',
typeVersion: 1.2,
position: [112, 768],
id: '4090a753-f131-40b1-87c3-cf74d5a7e325',
name: 'Get World News',
},
{
parameters: {
rule: {
interval: [
{
triggerAtHour: 7,
},
],
},
},
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1.2,
position: [-560, 752],
id: '651543b5-0213-433f-8760-57d62b8d6d64',
name: 'Run every day at 7AM',
notesInFlow: true,
notes: 'Double-click to open',
},
{
parameters: {
assignments: {
assignments: [
{
id: '85b5c530-2c13-4424-ab83-05979bc879a5',
name: 'output',
value: '={{ $json.output }}',
type: 'string',
},
],
},
options: {},
},
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [160, 544],
id: '99f7bb9e-f8c0-43ca-a9a8-a76634ac9611',
name: 'Output',
notesInFlow: true,
notes: 'Double-click to open',
},
{
parameters: {},
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [-560, 544],
id: 'a0390291-6794-4673-9a6a-5c3d3a5d9e4b',
name: 'Click Execute workflow to run',
},
{
parameters: {
content: '## ⚡ Start here:',
height: 224,
width: 224,
color: 7,
},
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [-624, 480],
id: 'fac5929f-e065-4474-96b1-7bcc06834238',
name: 'Sticky Note',
},
{
parameters: {
model: {
__rl: true,
mode: 'list',
value: 'gpt-4.1-mini',
},
options: {},
},
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1.2,
position: [-272, 768],
id: 'b16482e8-0d48-4426-aa93-c3fee11dd3cd',
name: 'OpenAI Model',
notesInFlow: true,
credentials: {},
notes: 'Double-click to open',
},
{
parameters: {
content: '@[youtube](cMyOkQ4N-5M)',
height: 512,
width: 902,
color: 7,
},
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [-352, -96],
id: 'ec65e69e-77fa-4912-a4af-49e0a248e2c8',
name: 'Sticky Note3',
},
{
parameters: {
promptType: 'define',
text: '=Summarize world news and tech news from the last 24 hours. \nSkip your comments. \nThe titles should be "World news:" and "Tech news:" \nToday is {{ $today }}',
options: {},
},
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2.2,
position: [-272, 544],
id: '084d56aa-d157-4964-9073-b36d9d9589c5',
name: 'AI Summary Agent',
notesInFlow: true,
notes: 'Double-click to open',
},
{
parameters: {
content: '### Double click here to see the results:',
height: 240,
width: 192,
color: 7,
},
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [112, 464],
id: 'a4b7a69a-0db8-4b9b-a81d-fd83378043a3',
name: 'Sticky Note1',
},
{
parameters: {
content:
'### 📰 Daily AI Summary\n\n\nThis workflow gets the latest news and asks AI to summarize it for you.\n\n⭐ Bonus: Send the summary via email by connecting your Gmail account\n\n▶ Watch the video to get started ',
height: 272,
width: 224,
color: 5,
},
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [-624, 32],
id: '74d80857-5e63-47a8-8e86-8ecd10fd5f9e',
name: 'Sticky Note2',
},
{
parameters: {
subject: 'Your news daily summary',
emailType: 'text',
message: '={{ $json.output }}',
options: {},
},
type: 'n8n-nodes-base.gmail',
typeVersion: 2.1,
position: [432, 544],
id: '45625d0d-bf26-4379-9eed-7bbc8e5d87a5',
name: 'Send summary by email',
webhookId: '093b04f1-5e78-4926-9863-1b100d6f2ead',
notesInFlow: true,
credentials: {},
notes: 'Double-click to open',
},
],
connections: {
'Get Tech News': {
ai_tool: [
[
{
node: 'AI Summary Agent',
type: 'ai_tool',
index: 0,
},
],
],
},
'Get World News': {
ai_tool: [
[
{
node: 'AI Summary Agent',
type: 'ai_tool',
index: 0,
},
],
],
},
'Run every day at 7AM': {
main: [
[
{
node: 'AI Summary Agent',
type: 'main',
index: 0,
},
],
],
},
'Click Execute workflow to run': {
main: [
[
{
node: 'AI Summary Agent',
type: 'main',
index: 0,
},
],
],
},
'OpenAI Model': {
ai_languageModel: [
[
{
node: 'AI Summary Agent',
type: 'ai_languageModel',
index: 0,
},
],
],
},
'AI Summary Agent': {
main: [
[
{
node: 'Output',
type: 'main',
index: 0,
},
],
],
},
Output: {
main: [[]],
},
},
pinData: {},
};

View File

@ -23,7 +23,7 @@ export class TestEntryComposer {
*/
async fromBlankCanvas() {
await this.n8n.goHome();
await this.n8n.workflows.addResource.workflow();
await this.n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
// Verify we're on canvas
await this.n8n.canvas.canvasPane().isVisible();
}

View File

@ -49,6 +49,23 @@ export class WorkflowComposer {
await responsePromise;
}
/**
* Creates a new workflow by clicking the add workflow button
* @param workflowName - The name of the workflow to create
*/
async createWorkflowFromSidebar(workflowName = 'My New Workflow') {
await this.n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await this.n8n.canvas.setWorkflowName(workflowName);
const responsePromise = this.n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows') && response.request().method() === 'POST',
);
await this.n8n.canvas.saveWorkflow();
await responsePromise;
}
/**
* Creates a new workflow by importing a JSON file
* @param fileName - The workflow JSON file name (e.g., 'test_pdf_workflow.json', will search in workflows folder)
@ -153,9 +170,9 @@ export class WorkflowComposer {
});
const workflows = await response.json();
return workflows.data[0];
}
/**
}
/**
* Moves a workflow to a different project or user.
* @param workflowName - The name of the workflow to move
* @param projectNameOrEmail - The destination project name or user email

View File

@ -24,6 +24,8 @@ test.describe('Workflows', () => {
test('should create a new workflow using add workflow button and save successfully', async ({
n8n,
}) => {
const { projectId } = await n8n.projectComposer.createProject();
await n8n.page.goto(`projects/${projectId}/workflows`);
await n8n.workflows.addResource.workflow();
const uniqueIdForCreate = nanoid(8);
@ -39,7 +41,7 @@ test.describe('Workflows', () => {
const specificName = `Specific Test ${uniqueId}`;
const genericName = `Generic Test ${uniqueId}`;
await n8n.workflowComposer.createWorkflow(specificName);
await n8n.workflowComposer.createWorkflowFromSidebar(specificName);
await n8n.goHome();
await n8n.workflowComposer.createWorkflow(genericName);
await n8n.goHome();
@ -63,7 +65,7 @@ test.describe('Workflows', () => {
test('should archive and unarchive a workflow', async ({ n8n }) => {
const uniqueIdForArchive = nanoid(8);
const workflowName = `Archive Test ${uniqueIdForArchive}`;
await n8n.workflowComposer.createWorkflow(workflowName);
await n8n.workflowComposer.createWorkflowFromSidebar(workflowName);
await n8n.goHome();
// Create a second workflow so we can still see filters
@ -85,7 +87,7 @@ test.describe('Workflows', () => {
test('should delete an archived workflow', async ({ n8n }) => {
const uniqueIdForDelete = nanoid(8);
const workflowName = `Delete Test ${uniqueIdForDelete}`;
await n8n.workflowComposer.createWorkflow(workflowName);
await n8n.workflowComposer.createWorkflowFromSidebar(workflowName);
await n8n.goHome();
await n8n.workflowComposer.createWorkflow();
await n8n.goHome();

View File

@ -14,7 +14,7 @@ import { resolveFromRoot } from '../../utils/path-helper';
test.describe('Undo/Redo', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
});
test('should undo/redo deleting node using context menu', async ({ n8n }) => {

View File

@ -10,7 +10,7 @@ import { test, expect } from '../../fixtures/base';
test.describe('Canvas Actions', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
});
test('should add first step', async ({ n8n }) => {

View File

@ -33,7 +33,7 @@ test.describe('Data pinning', () => {
test.describe('Pin data operations', () => {
test('should be able to pin node output', async ({ n8n }) => {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
await n8n.ndv.execute();
@ -52,7 +52,7 @@ test.describe('Data pinning', () => {
});
test('should be able to set custom pinned data', async ({ n8n }) => {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
await expect(n8n.ndv.getEditPinnedDataButton()).toBeVisible();
@ -74,7 +74,7 @@ test.describe('Data pinning', () => {
});
test('should display pin data edit button for Webhook node', async ({ n8n }) => {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode(NODES.WEBHOOK);
const runDataHeader = n8n.ndv.getRunDataPaneHeader();
@ -83,7 +83,7 @@ test.describe('Data pinning', () => {
});
test('should duplicate pinned data when duplicating node', async ({ n8n }) => {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
await n8n.ndv.close();
@ -117,7 +117,7 @@ test.describe('Data pinning', () => {
});
expect(actualMaxSize).toBe(maxPinnedDataSize);
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
await n8n.ndv.close();
@ -138,7 +138,7 @@ test.describe('Data pinning', () => {
});
test('should show error when pin data JSON is invalid', async ({ n8n }) => {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
await n8n.ndv.close();
@ -158,7 +158,7 @@ test.describe('Data pinning', () => {
test.describe('Advanced pinning scenarios', () => {
test('should be able to reference paired items in node before pinned data', async ({ n8n }) => {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode(NODES.MANUAL_TRIGGER);
await n8n.canvas.addNode(NODES.HTTP_REQUEST);

View File

@ -16,7 +16,7 @@ test.describe('Data transformation expressions', () => {
}
test('$json + native string methods', async ({ n8n }) => {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.setPinnedData([{ myStr: 'Monday' }]);
@ -38,7 +38,7 @@ test.describe('Data transformation expressions', () => {
});
test('$json + n8n string methods', async ({ n8n }) => {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.setPinnedData([{ myStr: 'hello@n8n.io is an email' }]);
@ -59,7 +59,7 @@ test.describe('Data transformation expressions', () => {
});
test('$json + native numeric methods', async ({ n8n }) => {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.setPinnedData([{ myNum: 9.123 }]);
@ -80,7 +80,7 @@ test.describe('Data transformation expressions', () => {
});
test('$json + n8n numeric methods', async ({ n8n }) => {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.setPinnedData([{ myStr: 'hello@n8n.io is an email' }]);
@ -101,7 +101,7 @@ test.describe('Data transformation expressions', () => {
});
test('$json + native array access', async ({ n8n }) => {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.setPinnedData([{ myArr: [1, 2, 3] }]);
@ -123,7 +123,7 @@ test.describe('Data transformation expressions', () => {
});
test('$json + n8n array methods', async ({ n8n }) => {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.setPinnedData([{ myArr: [1, 2, 3] }]);

View File

@ -6,7 +6,7 @@ test.describe('Schedule Trigger node', () => {
});
test('should execute schedule trigger node and return timestamp in output', async ({ n8n }) => {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.execute();

View File

@ -45,7 +45,7 @@ test.describe('@isolated', () => {
await n8n.canvas.saveWorkflow();
await n8n.navigate.toWorkflows();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.importWorkflow('Test_workflow_1.json', 'Workflow W2');
});
@ -366,7 +366,7 @@ test.describe('@isolated', () => {
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflows();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
@ -384,7 +384,7 @@ test.describe('@isolated', () => {
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflows();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.setWorkflowName(workflowName);
await n8n.page.keyboard.press('Enter');
await n8n.canvas.openShareModal();
@ -431,7 +431,7 @@ test.describe('@isolated', () => {
await n8n.navigate.toCredentials();
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflows();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.setWorkflowName(workflowName);
await n8n.page.keyboard.press('Enter');
await n8n.canvas.openShareModal();
@ -466,7 +466,7 @@ test.describe('@isolated', () => {
await n8n.api.signin('owner');
await n8n.navigate.toWorkflows();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();

View File

@ -11,14 +11,14 @@ test.describe('User Management', () => {
INSTANCE_OWNER_CREDENTIALS.email,
INSTANCE_OWNER_CREDENTIALS.password,
);
await expect(n8n.workflows.getProjectName()).toBeVisible();
await expect(n8n.sideBar.getUserMenu()).toBeVisible();
});
test('should prevent non-owners to access UM settings', async ({ n8n }) => {
// This creates a new user in the same context, so the cookies are refreshed and owner is no longer logged in
await n8n.api.users.create();
await n8n.navigate.toUsers();
await expect(n8n.workflows.getProjectName()).toBeVisible();
await expect(n8n.workflows.getNewWorkflowCard()).toBeVisible();
});
test('should allow instance owner to access UM settings', async ({ n8n }) => {

View File

@ -4,7 +4,7 @@ test.describe('ADO-2270 Save button resets on webhook node open', () => {
test('should not reset the save button if webhook node is opened and closed', async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode('Webhook');
await n8n.page.keyboard.press('Escape');

View File

@ -41,6 +41,12 @@ const setupCloudTest = async (
await n8n.page.waitForLoadState();
};
const createProjectAndNavigate = async (n8n: n8nPage) => {
await n8n.goHome();
const { projectId } = await n8n.projectComposer.createProject();
await n8n.page.goto(`projects/${projectId}/workflows`);
};
test.describe('Cloud @db:reset @auth:owner', () => {
test.describe('Trial Banner', () => {
test('should render trial banner for opt-in cloud user', async ({ n8n, setupRequirements }) => {
@ -84,7 +90,7 @@ test.describe('Cloud @db:reset @auth:owner', () => {
await n8n.api.setEnvFeatureFlags({ '026_easy_ai_workflow': 'control' });
await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements);
await n8n.navigate.toWorkflows();
await createProjectAndNavigate(n8n);
await expect(n8n.workflows.getEasyAiWorkflowCard()).toBeHidden();
});
@ -96,7 +102,7 @@ test.describe('Cloud @db:reset @auth:owner', () => {
await n8n.api.setEnvFeatureFlags({ '026_easy_ai_workflow': 'variant' });
await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements);
await n8n.navigate.toWorkflows();
await createProjectAndNavigate(n8n);
await expect(n8n.workflows.getEasyAiWorkflowCard()).toBeVisible();
});
@ -108,7 +114,7 @@ test.describe('Cloud @db:reset @auth:owner', () => {
await n8n.api.setEnvFeatureFlags({ '026_easy_ai_workflow': 'variant' });
await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements);
await n8n.navigate.toWorkflows();
await createProjectAndNavigate(n8n);
await n8n.workflows.clickEasyAiWorkflowCard();

View File

@ -24,7 +24,7 @@ test.describe('Debug mode', () => {
// Helper function to create basic workflow
async function createBasicWorkflow(n8n: n8nPage, url = URLS.FAILING) {
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('HTTP Request');
await n8n.ndv.fillParameterInput('URL', url);

View File

@ -4,7 +4,7 @@ import { test, expect } from '../../fixtures/base';
test.describe('Resource Mapper', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode(E2E_TEST_NODE_NAME, { action: 'Resource Mapping Component' });
});

View File

@ -5,7 +5,7 @@ const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
test.describe('Workflow Production Checklist', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
});
test('should show suggested actions automatically when workflow is first activated', async ({

View File

@ -11,7 +11,7 @@ test.describe('Code node', () => {
test.describe('Code editor', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript' });
});
@ -95,7 +95,7 @@ return
.serial('Run Once for Each Item', () => {
test('should show lint errors in `runOnceForEachItem` mode', async ({ n8n }) => {
await n8n.start.fromHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript' });
await n8n.ndv.toggleCodeMode('Run Once for Each Item');
@ -120,7 +120,7 @@ return []
test.beforeEach(async ({ api, n8n }) => {
await api.enableFeature('askAi');
await n8n.goHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript' });
});

View File

@ -262,7 +262,7 @@ test.describe('Workflow Actions', () => {
response.url().includes('/rest/workflows') && response.request().method() === 'GET',
);
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
const workflowsResponse = await workflowsResponsePromise;
const responseBody = await workflowsResponse.json();

View File

@ -3,7 +3,7 @@ import { test, expect } from '../../fixtures/base';
test.describe('AI-716 Correctly set up agent model shows error', () => {
test('should not show error when adding a sub-node with credential set-up', async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode('AI Agent');

View File

@ -7,7 +7,7 @@ import { test, expect } from '../../../fixtures/base';
test.describe('Canvas Node Actions', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
});
test.describe('Node Search and Add', () => {

View File

@ -53,7 +53,7 @@ m82JpEptTfAxFHtd8+Sb0U2G
},
});
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
// Import the evaluations workflow
await n8n.canvas.importWorkflow('evaluations_loop.json', 'Evaluations');

View File

@ -3,7 +3,7 @@ import { expect, test } from '../../fixtures/base';
test.describe('PDF Test', () => {
test('Can read and write PDF files and extract text', async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.importWorkflow('test_pdf_workflow.json', 'PDF Workflow');
await n8n.canvas.clickExecuteWorkflowButton();
await expect(

View File

@ -198,6 +198,8 @@ test.describe('Security Notifications', () => {
await setupApiFailure(n8n);
await n8n.goHome();
const { projectId } = await n8n.projectComposer.createProject();
await n8n.page.goto(`projects/${projectId}/workflows`);
// Verify no security notification appears on API failure
const notification = n8n.notifications.getNotificationByTitle('Critical update available');

View File

@ -16,7 +16,7 @@ test.use({
test.describe('Task Runner Capability @capability:task-runner', () => {
test('should execute Javascript with task runner enabled', async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
@ -28,7 +28,7 @@ test.describe('Task Runner Capability @capability:task-runner', () => {
test('should execute Python with task runner enabled', async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in Python (Beta)', closeNDV: true });
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(

View File

@ -61,7 +61,7 @@ export async function setupTestRequirements(
try {
// Import workflow using the n8n page object
await n8n.goHome();
await n8n.workflows.addResource.workflow();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.importWorkflow(name, workflowData);
} catch (error) {
throw new TestError(`Failed to create workflow ${name}: ${String(error)}`);