diff --git a/.claude/plugins/n8n/skills/setup-experiment/SKILL.md b/.claude/plugins/n8n/skills/setup-experiment/SKILL.md new file mode 100644 index 00000000000..a3334b09758 --- /dev/null +++ b/.claude/plugins/n8n/skills/setup-experiment/SKILL.md @@ -0,0 +1,14 @@ +--- +name: setup-experiment +description: >- + Scaffolds new `packages/frontend/editor-ui` experiments using the n8n + experiment standard. Use when creating a new editor-ui experiment, adding a + PostHog experiment scaffold, or setting up experiment files for a new + experiment. +--- + +# Setup experiment + +Use this skill when scaffolding a new `packages/frontend/editor-ui` experiment. + +Follow [reference.md](reference.md) for the canonical workflow, scaffold rules, and output reminders. diff --git a/.claude/plugins/n8n/skills/setup-experiment/reference.md b/.claude/plugins/n8n/skills/setup-experiment/reference.md new file mode 100644 index 00000000000..1ae1d166aad --- /dev/null +++ b/.claude/plugins/n8n/skills/setup-experiment/reference.md @@ -0,0 +1,140 @@ +# Setup experiment reference + +Use this workflow when scaffolding a new experiment under `packages/frontend/editor-ui/src/experiments/`. + +## Ask or infer + +1. Experiment folder name, for example `setupPanelV2`. +2. Experiment constant name, for example `SETUP_PANEL_V2_EXPERIMENT`. +3. Variant shape: + - simple `control/variant`, or + - multi-variant. +4. Public API shape: + - default to a Pinia store, + - only use a composable when the experiment is genuinely tiny and stateless. +5. Whether persisted UI state is needed. +6. Whether optional `components/`, `data/`, `workflows/`, or host-surface wiring are in scope. + +## Always do + +1. Start in `packages/frontend/editor-ui/src/app/constants/experiments.ts`. +2. Add the experiment constant. +3. Add the experiment name to `EXPERIMENTS_TO_TRACK`. +4. Create `packages/frontend/editor-ui/src/experiments//`. +5. Pick one public API path: + - Store-backed by default: add a `STORES.EXPERIMENT_*` entry in `packages/frontend/@n8n/stores/src/constants.ts`, then create `stores/.store.ts` and `stores/.store.test.ts`. + - Tiny stateless composable only by exception: skip the `STORES.EXPERIMENT_*` entry and create a single composable such as `useExperiment.ts` with an adjacent test when needed. +6. If the experiment needs persisted UI state or a shared mutable API, use the store path, not the composable path. + +## Enrollment standard + +- Use `isEnabled` for PostHog enrollment only. +- For simple experiments, use `posthogStore.isVariantEnabled(EXPERIMENT.name, EXPERIMENT.variant)`. +- For multi-variant experiments, expose `currentVariant` and derive booleans from it. +- Keep display state separate with `shouldShow*`, `can*`, `hasDismissed*`, or `hasInteracted*`. + +Do not mix enrollment with: + +- `userIsTrialing`, +- cloud-only checks, +- selected-app checks, +- workflow-count checks, +- dismissal state. + +If the user wants those conditions, treat them as local display logic, not enrollment logic. + +## Persistence standard + +Use `packages/frontend/editor-ui/src/app/composables/useStorage.ts` for experiment-owned persisted UI state. + +Use it for: + +- dismissed state, +- interaction state, +- last-seen state. + +Do not use raw `localStorage` for new experiment-owned state unless the experiment must touch an existing non-experiment app key. + +## Telemetry standard + +- Keep `"User is part of experiment"` tracking centralized. +- Scaffold only experiment-specific `track*` helpers such as `trackViewed`, `trackClicked`, `trackDismissed`, or `trackCompleted`. + +## Store scaffold + +Default to this store shape and adapt names as needed: + +```ts +import { useTelemetry } from '@/app/composables/useTelemetry'; +import { useStorage } from '@/app/composables/useStorage'; +import { SETUP_PANEL_V2_EXPERIMENT } from '@/app/constants'; +import { usePostHog } from '@/app/stores/posthog.store'; +import { STORES } from '@n8n/stores'; +import { defineStore } from 'pinia'; +import { computed } from 'vue'; + +const DISMISSED_KEY = 'N8N_SETUP_PANEL_V2_DISMISSED'; + +export const useSetupPanelV2Store = defineStore(STORES.EXPERIMENT_SETUP_PANEL_V2, () => { + const posthogStore = usePostHog(); + const telemetry = useTelemetry(); + const dismissed = useStorage(DISMISSED_KEY); + + const isEnabled = computed(() => + posthogStore.isVariantEnabled( + SETUP_PANEL_V2_EXPERIMENT.name, + SETUP_PANEL_V2_EXPERIMENT.variant, + ), + ); + const isDismissed = computed(() => dismissed.value === 'true'); + const shouldShowCallout = computed(() => isEnabled.value && !isDismissed.value); + + function dismiss() { + dismissed.value = 'true'; + } + + function trackViewed() { + telemetry.track('Setup panel v2 viewed'); + } + + return { + isEnabled, + isDismissed, + shouldShowCallout, + dismiss, + trackViewed, + }; +}); +``` + +## Test scaffold + +Default to one public API test next to the store. Use `stores/.store.test.ts` as the standard location. Mirror the local style from: + +- `packages/frontend/editor-ui/src/experiments/credentialsAppSelection/stores/credentialsAppSelection.store.test.ts` +- `packages/frontend/editor-ui/src/experiments/resourceCenter/stores/__tests__/resourceCenter.store.test.ts` + +Use `__tests__/` only when the surrounding experiment already follows that layout or when the local package pattern makes colocating the test awkward. + +The default assertions should cover: + +1. enrollment logic, +2. persisted state when present, +3. experiment-specific telemetry helpers when present. + +## Do not do by default + +- Do not wire routes, layouts, sidebars, or modals automatically. +- Do not add empty `components/`, `data/`, or `workflows/` folders unless requested. +- Do not add product gating on top of PostHog enrollment unless the user explicitly wants local display logic. +- Do not scaffold participation tracking inside the experiment. + +## Output reminder + +At the end of the scaffold, explicitly tell the user what is still manual, for example: + +- host-surface wiring, +- i18n keys, +- route guards, +- modal registration, +- extra workflow payload files. diff --git a/.code-health-baseline.json b/.code-health-baseline.json index 62dabbf4cb7..0ea3900b19e 100644 --- a/.code-health-baseline.json +++ b/.code-health-baseline.json @@ -1,7 +1,7 @@ { "version": 1, - "generated": "2026-05-12T09:37:31.489Z", - "totalViolations": 82, + "generated": "2026-05-13T10:23:52.870Z", + "totalViolations": 87, "violations": { "packages/@n8n/ai-workflow-builder.ee/package.json": [ { @@ -52,13 +52,25 @@ "packages/@n8n/nodes-langchain/package.json": [ { "rule": "catalog-violations", - "line": 292, + "line": 266, + "message": "@n8n/typeorm@0.3.20-16 should use \"catalog:\" (exists in pnpm-workspace.yaml)", + "hash": "a7c1bca1c439" + }, + { + "rule": "catalog-violations", + "line": 267, + "message": "mysql2@3.17.0 should use \"catalog:\" (exists in pnpm-workspace.yaml)", + "hash": "2d7d1b715d0c" + }, + { + "rule": "catalog-violations", + "line": 293, "message": "openai@^6.34.0 should use \"catalog:\" (exists in pnpm-workspace.yaml)", "hash": "3c1f53f0afe3" }, { "rule": "catalog-violations", - "line": 299, + "line": 300, "message": "tmp-promise appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "88d67e2ef747" }, @@ -70,25 +82,25 @@ }, { "rule": "catalog-violations", - "line": 274, + "line": 275, "message": "cheerio appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "8cd029bb871e" }, { "rule": "catalog-violations", - "line": 284, + "line": 285, "message": "jsdom appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "26f20ebea4b1" }, { "rule": "catalog-violations", - "line": 289, + "line": 290, "message": "mongodb appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "46cb48884e22" }, { "rule": "catalog-violations", - "line": 293, + "line": 294, "message": "pdf-parse appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "0c7d44a9c2e4" } @@ -220,49 +232,49 @@ "packages/@n8n/instance-ai/package.json": [ { "rule": "catalog-violations", - "line": 80, + "line": 81, "message": "@ai-sdk/anthropic appears in 3 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "5b2153508e47" }, { "rule": "catalog-violations", - "line": 86, + "line": 87, "message": "@types/psl appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "56dabb51b433" }, { "rule": "catalog-violations", - "line": 56, + "line": 57, "message": "@mozilla/readability appears in 5 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "8fa6b9a8fc91" }, { "rule": "catalog-violations", - "line": 64, + "line": 65, "message": "csv-parse appears in 3 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "8f082fc2e8b6" }, { "rule": "catalog-violations", - "line": 71, + "line": 72, "message": "turndown appears in 3 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "9a9d97065952" }, { "rule": "catalog-violations", - "line": 87, + "line": 88, "message": "@types/turndown appears in 3 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "12e346c47b39" }, { "rule": "catalog-violations", - "line": 50, + "line": 51, "message": "@joplin/turndown-plugin-gfm appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "a3cf1504b5c2" }, { "rule": "catalog-violations", - "line": 68, + "line": 69, "message": "pdf-parse appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "283fa9114c03" } @@ -290,61 +302,61 @@ "packages/nodes-base/package.json": [ { "rule": "catalog-violations", - "line": 911, + "line": 913, "message": "change-case appears in 5 packages with 3 different versions — add to pnpm-workspace.yaml catalog", "hash": "2d1fab7a5b05" }, { "rule": "catalog-violations", - "line": 961, + "line": 963, "message": "semver appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "2daf37aa14e4" }, { "rule": "catalog-violations", - "line": 966, + "line": 968, "message": "tmp-promise appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "3f93c404ae9c" }, { "rule": "catalog-violations", - "line": 900, + "line": 902, "message": "@mozilla/readability appears in 5 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "ca4ac788adc6" }, { "rule": "catalog-violations", - "line": 912, + "line": 914, "message": "cheerio appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "1a1b5bbc50c9" }, { "rule": "catalog-violations", - "line": 915, + "line": 917, "message": "csv-parse appears in 3 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "781db4a1e068" }, { "rule": "catalog-violations", - "line": 917, + "line": 919, "message": "eventsource appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "9795e6c6d9e9" }, { "rule": "catalog-violations", - "line": 930, + "line": 932, "message": "jsdom appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "02341f2b5e3e" }, { "rule": "catalog-violations", - "line": 941, + "line": 943, "message": "mongodb appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "f688907d087a" }, { "rule": "catalog-violations", - "line": 892, + "line": 894, "message": "eslint-plugin-n8n-nodes-base appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "ac254baa61f9" } @@ -384,19 +396,19 @@ }, { "rule": "catalog-violations", - "line": 90, + "line": 91, "message": "prettier appears in 3 packages with 3 different versions — add to pnpm-workspace.yaml catalog", "hash": "9e9c7ec09a0b" }, { "rule": "catalog-violations", - "line": 92, + "line": 93, "message": "semver appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "d8c606e42c92" }, { "rule": "catalog-violations", - "line": 77, + "line": 78, "message": "esprima-next appears in 3 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "62156c2613b2" } @@ -491,6 +503,14 @@ "hash": "733c3960022e" } ], + "packages/@n8n/computer-use/package.json": [ + { + "rule": "catalog-violations", + "line": 47, + "message": "eventsource appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog", + "hash": "f50c1eee2ed6" + } + ], "packages/workflow/package.json": [ { "rule": "catalog-violations", @@ -511,14 +531,6 @@ "hash": "b660317b5f6f" } ], - "packages/@n8n/computer-use/package.json": [ - { - "rule": "catalog-violations", - "line": 47, - "message": "eventsource appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog", - "hash": "f50c1eee2ed6" - } - ], "packages/@n8n/eslint-plugin-community-nodes/package.json": [ { "rule": "catalog-violations", @@ -540,6 +552,30 @@ "message": "stylelint appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog", "hash": "955f3fe044c7" } + ], + "packages/@n8n/db/src/migrations/common/1783000000000-CreateAgentTables.ts": [ + { + "rule": "migration-timestamp", + "line": 1, + "message": "1783000000000-CreateAgentTables.ts is prefixed with a future timestamp (1783000000000, ~50d ahead of now). Migration prefixes must be the exact Date.now() value at the time of creation — AI agents commonly fabricate future timestamps.", + "hash": "d81428d567aa" + } + ], + "packages/@n8n/db/src/migrations/common/1783000000001-CreateAgentExecutionTables.ts": [ + { + "rule": "migration-timestamp", + "line": 1, + "message": "1783000000001-CreateAgentExecutionTables.ts is prefixed with a future timestamp (1783000000001, ~50d ahead of now). Migration prefixes must be the exact Date.now() value at the time of creation — AI agents commonly fabricate future timestamps.", + "hash": "e33a8a794126" + } + ], + "packages/@n8n/db/src/migrations/common/1784000000000-CreateAgentObservationTables.ts": [ + { + "rule": "migration-timestamp", + "line": 1, + "message": "1784000000000-CreateAgentObservationTables.ts is prefixed with a future timestamp (1784000000000, ~61d ahead of now). Migration prefixes must be the exact Date.now() value at the time of creation — AI agents commonly fabricate future timestamps.", + "hash": "4bae5ef065ad" + } ] } } diff --git a/.github/workflows/ci-pr-quality.yml b/.github/workflows/ci-pr-quality.yml index 33bad80fcd9..f13c783c43a 100644 --- a/.github/workflows/ci-pr-quality.yml +++ b/.github/workflows/ci-pr-quality.yml @@ -134,6 +134,7 @@ jobs: pnpm-workspace.yaml .code-health-baseline.json packages/testing/code-health/** + .github/workflows/** check-static-analysis: name: Static Analysis diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 1f7aed31203..85e3aa56bd2 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -221,6 +221,7 @@ export { type DataTableListSortBy, dateTimeSchema, dataTableColumnNameSchema, + dataTableIdSchema, } from './schemas/data-table.schema'; export type { @@ -439,6 +440,27 @@ export { type StartTestRunPayload, } from './schemas/evaluations.schema'; +export { + EVAL_COLLECTIONS_FLAG, + evalCollectionVersionEntrySchema, + createEvaluationCollectionSchema, + CreateEvaluationCollectionDto, + updateEvaluationCollectionSchema, + UpdateEvaluationCollectionDto, + addRunToCollectionSchema, + AddRunToCollectionDto, + type EvalCollectionVersionEntry, + type CreateEvaluationCollectionPayload, + type UpdateEvaluationCollectionPayload, + type AddRunToCollectionPayload, + type EvalCollectionRunStatus, + type EvaluationCollectionRecord, + type EvaluationCollectionRunSummary, + type EvaluationCollectionDetail, + type EvalVersionEntry, + type EvalVersionsResponse, +} from './schemas/eval-collections.schema'; + export { ALLOWED_DOMAINS, isAllowedDomain } from './utils/allowed-domains'; export type { PublishTimelineEvent } from './schemas/workflow-publish-timeline.schema'; diff --git a/packages/@n8n/api-types/src/schemas/data-table.schema.ts b/packages/@n8n/api-types/src/schemas/data-table.schema.ts index 00e281e21bc..c8c10c9d451 100644 --- a/packages/@n8n/api-types/src/schemas/data-table.schema.ts +++ b/packages/@n8n/api-types/src/schemas/data-table.schema.ts @@ -5,7 +5,10 @@ import type { ListDataTableQueryDto } from '../dto'; export const insertRowReturnType = z.union([z.literal('all'), z.literal('count'), z.literal('id')]); export const dataTableNameSchema = z.string().trim().min(1).max(128); -export const dataTableIdSchema = z.string().max(36); +export const dataTableIdSchema = z + .string() + .max(36) + .regex(/^[a-zA-Z0-9]+$/); // Postgres does not allow leading numbers or - export const DATA_TABLE_COLUMN_REGEX = /^[a-zA-Z][a-zA-Z0-9_]*$/; diff --git a/packages/@n8n/api-types/src/schemas/eval-collections.schema.ts b/packages/@n8n/api-types/src/schemas/eval-collections.schema.ts new file mode 100644 index 00000000000..256b07eb817 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/eval-collections.schema.ts @@ -0,0 +1,112 @@ +import { z } from 'zod'; + +import { Z } from '../zod-class'; + +// Mirrors the `TestRunStatus` union in `@n8n/db`. Duplicated here so +// `@n8n/api-types` does not need to depend on the db package. +export type EvalCollectionRunStatus = 'new' | 'running' | 'completed' | 'error' | 'cancelled'; + +// PostHog rollout flag id gating the eval-collections feature surface. All +// new endpoints + frontend entry points consult this; flag-off cohort sees +// the legacy single-run flow unchanged. Single source of truth shared +// between FE and BE so the two cannot drift. Matches the spec verbatim — +// the existing `080_eval_parallel_execution` flag follows a numeric-prefix +// convention but the spec called for the literal id here. +export const EVAL_COLLECTIONS_FLAG = 'eval_collections'; + +// Per-version entry on a create-collection request. Either reference an +// existing test run (`existingTestRunId`) to reuse it, or omit it and the +// service will schedule a fresh run pinned to `workflowVersionId`. A null +// `workflowVersionId` means "current draft" — the service snapshots a new +// `WorkflowHistory` row at run start so the run stays comparable. +export const evalCollectionVersionEntrySchema = z.object({ + workflowVersionId: z.string().min(1).nullable(), + existingTestRunId: z.string().min(1).optional(), + label: z.string().min(1).max(128).optional(), +}); + +export type EvalCollectionVersionEntry = z.infer; + +const createEvaluationCollectionShape = { + name: z.string().min(1).max(128), + description: z.string().nullable().optional(), + evaluationConfigId: z.string().min(1), + versions: z.array(evalCollectionVersionEntrySchema).min(1), + concurrency: z.number().int().min(1).max(10).optional(), +}; + +export const createEvaluationCollectionSchema = z.object(createEvaluationCollectionShape); +export type CreateEvaluationCollectionPayload = z.infer; + +export class CreateEvaluationCollectionDto extends Z.class(createEvaluationCollectionShape) {} + +const updateEvaluationCollectionShape = { + name: z.string().min(1).max(128).optional(), + description: z.string().nullable().optional(), +}; + +export const updateEvaluationCollectionSchema = z.object(updateEvaluationCollectionShape); +export type UpdateEvaluationCollectionPayload = z.infer; + +export class UpdateEvaluationCollectionDto extends Z.class(updateEvaluationCollectionShape) {} + +const addRunToCollectionShape = { + testRunId: z.string().min(1), +}; + +export const addRunToCollectionSchema = z.object(addRunToCollectionShape); +export type AddRunToCollectionPayload = z.infer; + +export class AddRunToCollectionDto extends Z.class(addRunToCollectionShape) {} + +// Response shapes. These are not validated input — they describe what the +// server returns. Keeping them as plain TypeScript types (not zod schemas) +// avoids forcing the server to round-trip its own outputs through zod. + +export type EvaluationCollectionRecord = { + id: string; + name: string; + description: string | null; + workflowId: string; + evaluationConfigId: string; + createdById: string | null; + createdAt: string; + updatedAt: string; + runCount: number; +}; + +export type EvaluationCollectionRunSummary = { + testRunId: string; + workflowVersionId: string | null; + status: EvalCollectionRunStatus; + runAt: string | null; + completedAt: string | null; + avgScore: number | null; + metrics: Record | null; +}; + +export type EvaluationCollectionDetail = EvaluationCollectionRecord & { + runs: EvaluationCollectionRunSummary[]; +}; + +// Versions-picker endpoint response. The setup wizard renders one row per +// item; `lastRun = null` means "no run yet · will run new" in the UI. +export type EvalVersionEntry = { + workflowVersionId: string | null; + label: string; + sourceLabel: string; + isCurrent: boolean; + lastRun: { + testRunId: string; + runAt: string; + status: EvalCollectionRunStatus; + avgScore: number | null; + isBest: boolean; + isCritical: boolean; + } | null; +}; + +export type EvalVersionsResponse = { + evaluationConfigId: string; + versions: EvalVersionEntry[]; +}; diff --git a/packages/@n8n/db/src/entities/evaluation-collection.ee.ts b/packages/@n8n/db/src/entities/evaluation-collection.ee.ts new file mode 100644 index 00000000000..32b0e7a1dff --- /dev/null +++ b/packages/@n8n/db/src/entities/evaluation-collection.ee.ts @@ -0,0 +1,58 @@ +import { Column, Entity, Index, PrimaryColumn } from '@n8n/typeorm'; +import type { IDataObject } from 'n8n-workflow'; + +import { JsonColumn, WithTimestamps } from './abstract-entity'; + +/** + * A named, user-curated group of {@link TestRun}s sharing one + * {@link EvaluationConfig} (= one dataset + one set of metrics + one workflow), + * typically spread across multiple workflow versions. The collection is the + * unit of comparison in the eval compare view. + * + * Invariants enforced at the service layer: + * - Every run in a collection has the same `evaluationConfigId`. + * - Every run is on the same workflow as the collection. + * - Runs typically differ by `workflowVersionId`. + * + * **TypeORM relations:** the `workflowId` and `evaluationConfigId` columns + * are FK columns *without* `@ManyToOne` decorators. Service code resolves + * the related entities through their own repositories. Declaring the + * relations on this entity would extend the TypeORM entity-relation type + * graph enough to trip TS2589 ("type instantiation excessively deep") in + * unrelated repositories that transitively reach `TestRun` → + * `EvaluationCollection` through `WorkflowEntity.testRuns` — e.g. + * `FolderRepository.upsert()` in `source-control-import.service.ee.ts`. + * The FK constraints are preserved by the migration's raw SQL; the + * decorators are purely TypeORM-side metadata. + */ +@Entity() +export class EvaluationCollection extends WithTimestamps { + @PrimaryColumn({ type: 'varchar', length: 36 }) + id: string; + + @Column('varchar', { length: 128 }) + name: string; + + @Column('text', { nullable: true }) + description: string | null; + + @Index() + @Column('varchar', { length: 36 }) + workflowId: string; + + @Index() + @Column('varchar', { length: 36 }) + evaluationConfigId: string; + + @Column({ type: 'uuid', nullable: true }) + createdById: string | null; + + /** + * Cached AI-generated insights for this collection. Lazily populated when + * a user opens the compare view; re-generated when runs change. Shape + * mirrors the `AiInsightsResponse` type produced by the insights agent — + * stored loose here so PR 2 can iterate on the schema without a migration. + */ + @JsonColumn({ nullable: true }) + insightsCache: IDataObject | null; +} diff --git a/packages/@n8n/db/src/entities/index.ts b/packages/@n8n/db/src/entities/index.ts index 00f866dca10..ba3286c45b3 100644 --- a/packages/@n8n/db/src/entities/index.ts +++ b/packages/@n8n/db/src/entities/index.ts @@ -11,6 +11,7 @@ import { } from './credential-dependency-entity'; import { CredentialsEntity } from './credentials-entity'; import { DeploymentKey } from './deployment-key'; +import { EvaluationCollection } from './evaluation-collection.ee'; import { EvaluationConfig } from './evaluation-config.ee'; import { ExecutionAnnotation } from './execution-annotation.ee'; import { ExecutionData } from './execution-data'; @@ -63,6 +64,7 @@ export { CredentialDependency, type CredentialDependencyType, DeploymentKey, + EvaluationCollection, EvaluationConfig, Folder, Project, @@ -109,6 +111,7 @@ export const entities = { CredentialsEntity, CredentialDependency, DeploymentKey, + EvaluationCollection, EvaluationConfig, Folder, Project, diff --git a/packages/@n8n/db/src/entities/test-run.ee.ts b/packages/@n8n/db/src/entities/test-run.ee.ts index c415194f319..dfb5c249163 100644 --- a/packages/@n8n/db/src/entities/test-run.ee.ts +++ b/packages/@n8n/db/src/entities/test-run.ee.ts @@ -1,4 +1,4 @@ -import { Column, Entity, OneToMany, ManyToOne } from '@n8n/typeorm'; +import { Column, Entity, Index, OneToMany, ManyToOne } from '@n8n/typeorm'; import type { IDataObject } from 'n8n-workflow'; import { DateTimeColumn, JsonColumn, WithTimestampsAndStringId } from './abstract-entity'; @@ -63,6 +63,30 @@ export class TestRun extends WithTimestampsAndStringId { @Column('boolean', { default: false }) cancelRequested: boolean; + @Column('varchar', { length: 36, nullable: true }) + workflowVersionId: string | null; + + // FK column only — no `@ManyToOne` decorator. Service code queries via + // `evaluationConfigId` and resolves the config through its own repository + // when needed. Declaring the relation on `TestRun` would extend the + // TypeORM entity-relation type graph enough to trip TS2589 + // ("type instantiation excessively deep") in unrelated repositories that + // transitively reach `TestRun` through `WorkflowEntity.testRuns` — e.g. + // `FolderRepository.upsert()` in `source-control-import.service.ee.ts`. + // The FK constraint is preserved by the migration's raw SQL; the + // decorator is purely TypeORM-side metadata. + @Index() + @Column('varchar', { length: 36, nullable: true }) + evaluationConfigId: string | null; + + @JsonColumn({ nullable: true }) + evaluationConfigSnapshot: IDataObject | null; + + // FK column only — see `evaluationConfigId` comment above for rationale. + @Index() + @Column('varchar', { length: 36, nullable: true }) + collectionId: string | null; + /** * Calculated property to determine the final result of the test run * depending on the statuses of test case executions diff --git a/packages/@n8n/db/src/migrations/common/1778496086558-CreateEvaluationCollection.ts b/packages/@n8n/db/src/migrations/common/1778496086558-CreateEvaluationCollection.ts new file mode 100644 index 00000000000..191d42369dd --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1778496086558-CreateEvaluationCollection.ts @@ -0,0 +1,85 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const EVALUATION_COLLECTION_TABLE = 'evaluation_collection'; +const TEST_RUN_TABLE = 'test_run'; +const EVALUATION_CONFIG_TABLE = 'evaluation_config'; +const WORKFLOW_TABLE = 'workflow_entity'; +const USER_TABLE = 'user'; +const FK_TEST_RUN_COLLECTION = 'FK_test_run_collection_id'; + +export class CreateEvaluationCollection1778496086558 implements ReversibleMigration { + async up({ + schemaBuilder: { createTable, addColumns, addForeignKey, createIndex, column }, + queryRunner, + isSqlite, + }: MigrationContext) { + await createTable(EVALUATION_COLLECTION_TABLE) + .withColumns( + column('id').varchar(36).primary.notNull, + column('name').varchar(128).notNull, + column('description').text, + column('workflowId').varchar(36).notNull, + column('evaluationConfigId').varchar(36).notNull, + column('createdById').uuid, + column('insightsCache').json, + ) + .withForeignKey('workflowId', { + tableName: WORKFLOW_TABLE, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('evaluationConfigId', { + tableName: EVALUATION_CONFIG_TABLE, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('createdById', { + tableName: USER_TABLE, + columnName: 'id', + onDelete: 'SET NULL', + }) + .withIndexOn('workflowId') + .withIndexOn('evaluationConfigId').withTimestamps; + + // On SQLite, `addColumns` and `addForeignKey` both trigger a full + // table rebuild via TypeORM's cached `Table` metadata. The prior + // migration `1778100002000-AddEvaluationConfigColumnsToTestRun` + // added the `evaluationConfigSnapshot` column on SQLite via raw + // `ALTER TABLE ADD COLUMN`, which TypeORM doesn't observe — so its + // cached metadata is stale by the time this migration runs. Without + // this refresh, the next rebuild silently drops the column and + // every subsequent `TestRun` insert fails with + // "SQLITE_ERROR: table test_run has no column named + // evaluationConfigSnapshot". `queryRunner.getTable()` reloads from + // PRAGMA table_info so the rebuild reads the actual current schema. + if (isSqlite) { + await queryRunner.getTable(TEST_RUN_TABLE); + } + + await addColumns(TEST_RUN_TABLE, [column('collectionId').varchar(36)]); + + await addForeignKey( + TEST_RUN_TABLE, + 'collectionId', + [EVALUATION_COLLECTION_TABLE, 'id'], + FK_TEST_RUN_COLLECTION, + 'SET NULL', + ); + + await createIndex(TEST_RUN_TABLE, ['collectionId']); + } + + async down({ + schemaBuilder: { dropColumns, dropTable, dropForeignKey, dropIndex }, + }: MigrationContext) { + await dropIndex(TEST_RUN_TABLE, ['collectionId']); + await dropForeignKey( + TEST_RUN_TABLE, + 'collectionId', + [EVALUATION_COLLECTION_TABLE, 'id'], + FK_TEST_RUN_COLLECTION, + ); + await dropColumns(TEST_RUN_TABLE, ['collectionId']); + await dropTable(EVALUATION_COLLECTION_TABLE); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 74776d778a6..b72e950247e 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -172,6 +172,7 @@ import { AddExecutionDeduplicationKey1778000000000 } from '../common/17780000000 import { CreateEvaluationConfig1778100000000 } from '../common/1778100000000-CreateEvaluationConfig'; import { AddWorkflowVersionToTestRun1778100001000 } from '../common/1778100001000-AddWorkflowVersionToTestRun'; import { AddEvaluationConfigColumnsToTestRun1778100002000 } from '../common/1778100002000-AddEvaluationConfigColumnsToTestRun'; +import { CreateEvaluationCollection1778496086558 } from '../common/1778496086558-CreateEvaluationCollection'; import { CreateAgentTables1783000000000 } from '../common/1783000000000-CreateAgentTables'; import { CreateAgentExecutionTables1783000000001 } from '../common/1783000000001-CreateAgentExecutionTables'; import { CreateAgentObservationTables1784000000000 } from '../common/1784000000000-CreateAgentObservationTables'; @@ -352,6 +353,7 @@ export const postgresMigrations: Migration[] = [ CreateEvaluationConfig1778100000000, AddWorkflowVersionToTestRun1778100001000, AddEvaluationConfigColumnsToTestRun1778100002000, + CreateEvaluationCollection1778496086558, CreateAgentTables1783000000000, CreateAgentExecutionTables1783000000001, CreateAgentObservationTables1784000000000, diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 24fe84335ce..26347676a6a 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -165,6 +165,7 @@ import { AddExecutionDeduplicationKey1778000000000 } from '../common/17780000000 import { CreateEvaluationConfig1778100000000 } from '../common/1778100000000-CreateEvaluationConfig'; import { AddWorkflowVersionToTestRun1778100001000 } from '../common/1778100001000-AddWorkflowVersionToTestRun'; import { AddEvaluationConfigColumnsToTestRun1778100002000 } from '../common/1778100002000-AddEvaluationConfigColumnsToTestRun'; +import { CreateEvaluationCollection1778496086558 } from '../common/1778496086558-CreateEvaluationCollection'; import { CreateAgentTables1783000000000 } from '../common/1783000000000-CreateAgentTables'; import { CreateAgentExecutionTables1783000000001 } from '../common/1783000000001-CreateAgentExecutionTables'; import { CreateAgentObservationTables1784000000000 } from '../common/1784000000000-CreateAgentObservationTables'; @@ -338,6 +339,7 @@ const sqliteMigrations: Migration[] = [ CreateEvaluationConfig1778100000000, AddWorkflowVersionToTestRun1778100001000, AddEvaluationConfigColumnsToTestRun1778100002000, + CreateEvaluationCollection1778496086558, CreateAgentTables1783000000000, CreateAgentExecutionTables1783000000001, CreateAgentObservationTables1784000000000, diff --git a/packages/@n8n/db/src/repositories/__tests__/evaluation-collection.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/evaluation-collection.repository.test.ts new file mode 100644 index 00000000000..831300a9a2b --- /dev/null +++ b/packages/@n8n/db/src/repositories/__tests__/evaluation-collection.repository.test.ts @@ -0,0 +1,237 @@ +import { Container } from '@n8n/di'; + +import { EvaluationCollection } from '../../entities/evaluation-collection.ee'; +import { TestRun } from '../../entities/test-run.ee'; +import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager'; +import { EvaluationCollectionRepository } from '../evaluation-collection.repository'; + +describe('EvaluationCollectionRepository', () => { + const entityManager = mockEntityManager(EvaluationCollection); + const repo = Container.get(EvaluationCollectionRepository); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('createCollection', () => { + it('persists the collection with insightsCache initialised to null', async () => { + (entityManager.create as jest.Mock).mockImplementation( + (_target: unknown, entityLike: unknown) => entityLike as EvaluationCollection, + ); + entityManager.save.mockImplementationOnce(async (_target, entity) => entity); + + await repo.createCollection({ + id: 'col-1', + name: 'C', + description: null, + workflowId: 'wf-1', + evaluationConfigId: 'cfg-1', + createdById: 'user-1', + }); + + const savedEntity = entityManager.save.mock.calls[0]?.[1]; + expect(savedEntity).toMatchObject({ + id: 'col-1', + name: 'C', + description: null, + workflowId: 'wf-1', + evaluationConfigId: 'cfg-1', + createdById: 'user-1', + insightsCache: null, + }); + }); + }); + + describe('findByIdAndWorkflowId', () => { + it('scopes the lookup to the route workflowId so foreign-workflow ids 404 the same as missing ids', async () => { + entityManager.findOne.mockResolvedValueOnce(null); + + const result = await repo.findByIdAndWorkflowId('col-x', 'wf-1'); + + expect(result).toBeNull(); + const callArgs = entityManager.findOne.mock.calls[0]; + expect(callArgs?.[1]).toEqual({ where: { id: 'col-x', workflowId: 'wf-1' } }); + }); + }); + + describe('listByWorkflowId', () => { + it('joins runCount onto each collection with one aggregate query', async () => { + const collections = [ + { id: 'col-a', workflowId: 'wf-1', createdAt: new Date() }, + { id: 'col-b', workflowId: 'wf-1', createdAt: new Date() }, + ] as EvaluationCollection[]; + entityManager.find.mockResolvedValueOnce(collections); + const qb = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValueOnce([ + { collectionId: 'col-a', count: '3' }, + { collectionId: 'col-b', count: '0' }, + ]), + }; + entityManager.createQueryBuilder.mockReturnValueOnce(qb as never); + + const result = await repo.listByWorkflowId('wf-1'); + + expect(entityManager.createQueryBuilder).toHaveBeenCalledTimes(1); + expect(result.map((c) => c.runCount)).toEqual([3, 0]); + }); + + it('returns an empty array without an aggregate query when no collections exist', async () => { + entityManager.find.mockResolvedValueOnce([]); + + const result = await repo.listByWorkflowId('wf-1'); + + expect(result).toEqual([]); + expect(entityManager.createQueryBuilder).not.toHaveBeenCalled(); + }); + }); + + describe('addRunsToCollection', () => { + it('updates TestRun.collectionId in bulk by id', async () => { + entityManager.update.mockResolvedValueOnce({ affected: 2, generatedMaps: [], raw: [] }); + + await repo.addRunsToCollection('col-1', ['tr-a', 'tr-b']); + + const callArgs = entityManager.update.mock.calls[0]; + expect(callArgs?.[0]).toBe(TestRun); + expect(callArgs?.[2]).toEqual({ collectionId: 'col-1' }); + }); + + it('is a no-op when given an empty id list', async () => { + await repo.addRunsToCollection('col-1', []); + expect(entityManager.update).not.toHaveBeenCalled(); + }); + }); + + describe('removeRunFromCollection', () => { + it('only unlinks runs that are actually in the named collection', async () => { + entityManager.update.mockResolvedValueOnce({ affected: 1, generatedMaps: [], raw: [] }); + + const affected = await repo.removeRunFromCollection('col-1', 'tr-a'); + + expect(affected).toBe(1); + const callArgs = entityManager.update.mock.calls[0]; + expect(callArgs?.[1]).toEqual({ id: 'tr-a', collectionId: 'col-1' }); + expect(callArgs?.[2]).toEqual({ collectionId: null }); + }); + + it('returns 0 when the run is not part of the collection', async () => { + entityManager.update.mockResolvedValueOnce({ affected: 0, generatedMaps: [], raw: [] }); + + expect(await repo.removeRunFromCollection('col-1', 'tr-missing')).toBe(0); + }); + }); + + describe('deleteByIdAndWorkflowId', () => { + it('counts runs before delete so callers can report runs_unlinked for telemetry', async () => { + entityManager.findOne.mockResolvedValueOnce({ id: 'col-1' } as EvaluationCollection); + entityManager.count.mockResolvedValueOnce(4); + entityManager.delete.mockResolvedValueOnce({ affected: 1, raw: [] }); + + const result = await repo.deleteByIdAndWorkflowId('col-1', 'wf-1'); + + expect(result).toEqual({ deleted: true, runsUnlinked: 4 }); + }); + + it('returns { deleted: false } without touching delete when the collection does not exist', async () => { + entityManager.findOne.mockResolvedValueOnce(null); + + const result = await repo.deleteByIdAndWorkflowId('col-missing', 'wf-1'); + + expect(result).toEqual({ deleted: false, runsUnlinked: 0 }); + expect(entityManager.delete).not.toHaveBeenCalled(); + }); + }); + + describe('updateInsightsCache', () => { + it('updates the insightsCache JSON column on the row', async () => { + entityManager.update.mockResolvedValueOnce({ affected: 1, generatedMaps: [], raw: [] }); + + await repo.updateInsightsCache('col-1', { winner: { versionLabel: 'A' } } as never); + + const callArgs = entityManager.update.mock.calls[0]; + expect(callArgs?.[1]).toBe('col-1'); + expect(callArgs?.[2]).toEqual({ insightsCache: { winner: { versionLabel: 'A' } } }); + }); + + it('accepts null to clear the cache', async () => { + entityManager.update.mockResolvedValueOnce({ affected: 1, generatedMaps: [], raw: [] }); + + await repo.updateInsightsCache('col-1', null); + + const callArgs = entityManager.update.mock.calls[0]; + expect(callArgs?.[2]).toEqual({ insightsCache: null }); + }); + }); + + describe('updateMeta', () => { + it('returns null without writing when the collection does not exist', async () => { + entityManager.findOne.mockResolvedValueOnce(null); + + const result = await repo.updateMeta('col-x', 'wf-1', { name: 'New' }); + + expect(result).toBeNull(); + expect(entityManager.save).not.toHaveBeenCalled(); + }); + + it('merges name + description onto the existing entity', async () => { + const existing = { + id: 'col-1', + name: 'Old', + description: 'old desc', + workflowId: 'wf-1', + } as EvaluationCollection; + entityManager.findOne.mockResolvedValueOnce(existing); + (entityManager.create as jest.Mock).mockImplementation( + (_target: unknown, entityLike: unknown) => entityLike as EvaluationCollection, + ); + entityManager.save.mockImplementationOnce(async (_target, entity) => entity); + + await repo.updateMeta('col-1', 'wf-1', { name: 'New', description: null }); + + const savedEntity = entityManager.save.mock.calls[0]?.[1]; + expect(savedEntity).toMatchObject({ + id: 'col-1', + name: 'New', + description: null, + }); + }); + + it('preserves description when only name is provided', async () => { + const existing = { + id: 'col-1', + name: 'Old', + description: 'old desc', + workflowId: 'wf-1', + } as EvaluationCollection; + entityManager.findOne.mockResolvedValueOnce(existing); + entityManager.save.mockImplementationOnce(async (_target, entity) => entity); + + await repo.updateMeta('col-1', 'wf-1', { name: 'New' }); + + const savedEntity = entityManager.save.mock.calls[0]?.[1] as EvaluationCollection; + expect(savedEntity.name).toBe('New'); + expect(savedEntity.description).toBe('old desc'); + }); + + it('preserves existing fields when payload props are undefined', async () => { + const existing = { + id: 'col-1', + name: 'Old', + description: 'old desc', + workflowId: 'wf-1', + } as EvaluationCollection; + entityManager.findOne.mockResolvedValueOnce(existing); + entityManager.save.mockImplementationOnce(async (_target, entity) => entity); + + await repo.updateMeta('col-1', 'wf-1', { name: undefined, description: undefined }); + + const savedEntity = entityManager.save.mock.calls[0]?.[1] as EvaluationCollection; + expect(savedEntity.name).toBe('Old'); + expect(savedEntity.description).toBe('old desc'); + }); + }); +}); diff --git a/packages/@n8n/db/src/repositories/evaluation-collection.repository.ts b/packages/@n8n/db/src/repositories/evaluation-collection.repository.ts new file mode 100644 index 00000000000..25282876c3e --- /dev/null +++ b/packages/@n8n/db/src/repositories/evaluation-collection.repository.ts @@ -0,0 +1,156 @@ +import { Service } from '@n8n/di'; +import { DataSource, In, Repository } from '@n8n/typeorm'; +import type { IDataObject } from 'n8n-workflow'; + +import { EvaluationCollection } from '../entities/evaluation-collection.ee'; +import { TestRun } from '../entities/test-run.ee'; + +export type EvaluationCollectionListItem = { + id: EvaluationCollection['id']; + name: EvaluationCollection['name']; + description: EvaluationCollection['description']; + workflowId: EvaluationCollection['workflowId']; + evaluationConfigId: EvaluationCollection['evaluationConfigId']; + createdById: EvaluationCollection['createdById']; + insightsCache: EvaluationCollection['insightsCache']; + createdAt: EvaluationCollection['createdAt']; + updatedAt: EvaluationCollection['updatedAt']; + runCount: number; +}; + +@Service() +export class EvaluationCollectionRepository extends Repository { + constructor(dataSource: DataSource) { + super(EvaluationCollection, dataSource.manager); + } + + async createCollection(input: { + id: string; + name: string; + description: string | null; + workflowId: string; + evaluationConfigId: string; + createdById: string | null; + }): Promise { + const entity = this.create({ + id: input.id, + name: input.name, + description: input.description, + workflowId: input.workflowId, + evaluationConfigId: input.evaluationConfigId, + createdById: input.createdById, + insightsCache: null, + }); + return await this.save(entity); + } + + async findByIdAndWorkflowId( + id: string, + workflowId: string, + ): Promise { + return await this.findOne({ where: { id, workflowId } }); + } + + /** + * Returns collections for a workflow with `runCount` populated. The list + * page uses this — it does not need the full run rows, just the count and + * the collection's own fields. + */ + async listByWorkflowId(workflowId: string): Promise { + const collections = await this.find({ + where: { workflowId }, + order: { createdAt: 'DESC' }, + }); + + if (collections.length === 0) return []; + + const runCounts = await this.manager + .createQueryBuilder(TestRun, 'tr') + .select('tr.collectionId', 'collectionId') + .addSelect('COUNT(tr.id)', 'count') + .where('tr.collectionId IN (:...ids)', { ids: collections.map((c) => c.id) }) + .groupBy('tr.collectionId') + .getRawMany<{ collectionId: string; count: string }>(); + + const countByCollectionId = new Map( + runCounts.map(({ collectionId, count }) => [collectionId, Number(count)]), + ); + + return collections.map((collection) => ({ + ...collection, + runCount: countByCollectionId.get(collection.id) ?? 0, + })); + } + + /** + * Returns the collection plus its runs ordered by createdAt ascending so + * the compare view can render versions left-to-right by run order. + */ + async getDetailByIdAndWorkflowId( + id: string, + workflowId: string, + ): Promise<{ collection: EvaluationCollection; runs: TestRun[] } | null> { + const collection = await this.findOne({ where: { id, workflowId } }); + if (!collection) return null; + + const runs = await this.manager.find(TestRun, { + where: { collectionId: id }, + order: { createdAt: 'ASC' }, + }); + + return { collection, runs }; + } + + async addRunsToCollection(collectionId: string, testRunIds: string[]): Promise { + if (testRunIds.length === 0) return; + await this.manager.update(TestRun, { id: In(testRunIds) }, { collectionId }); + } + + async removeRunFromCollection(collectionId: string, testRunId: string): Promise { + const result = await this.manager.update( + TestRun, + { id: testRunId, collectionId }, + { collectionId: null }, + ); + return result.affected ?? 0; + } + + /** + * Returns how many runs were unlinked (set to `collectionId = null`) by + * the delete. Callers feed this into the `runs_unlinked` telemetry field. + * + * The FK on `test_run.collectionId` is `SET NULL`, so we'd technically + * get the unlink for free — but counting runs first lets the caller + * surface the number in the response and telemetry without an extra + * round-trip. + */ + async deleteByIdAndWorkflowId( + id: string, + workflowId: string, + ): Promise<{ deleted: boolean; runsUnlinked: number }> { + const collection = await this.findOne({ where: { id, workflowId } }); + if (!collection) return { deleted: false, runsUnlinked: 0 }; + + const runsUnlinked = await this.manager.count(TestRun, { where: { collectionId: id } }); + + await this.delete({ id }); + + return { deleted: true, runsUnlinked }; + } + + async updateInsightsCache(id: string, insights: IDataObject | null): Promise { + await this.update(id, { insightsCache: insights }); + } + + async updateMeta( + id: string, + workflowId: string, + payload: { name?: string; description?: string | null }, + ): Promise { + const existing = await this.findByIdAndWorkflowId(id, workflowId); + if (!existing) return null; + if (payload.name !== undefined) existing.name = payload.name; + if (payload.description !== undefined) existing.description = payload.description; + return await this.save(existing); + } +} diff --git a/packages/@n8n/db/src/repositories/index.ts b/packages/@n8n/db/src/repositories/index.ts index e6d80503d88..c17f877d18b 100644 --- a/packages/@n8n/db/src/repositories/index.ts +++ b/packages/@n8n/db/src/repositories/index.ts @@ -13,6 +13,10 @@ export { type DeploymentKeySortDirection, type ListDeploymentKeysOptions, } from './deployment-key.repository'; +export { + EvaluationCollectionRepository, + type EvaluationCollectionListItem, +} from './evaluation-collection.repository'; export { EvaluationConfigRepository } from './evaluation-config.repository'; export { ExecutionAnnotationRepository } from './execution-annotation.repository'; export { ExecutionDataRepository } from './execution-data.repository'; diff --git a/packages/@n8n/db/src/repositories/test-run.repository.ee.ts b/packages/@n8n/db/src/repositories/test-run.repository.ee.ts index 56cb27d82b7..4785f93bb0f 100644 --- a/packages/@n8n/db/src/repositories/test-run.repository.ee.ts +++ b/packages/@n8n/db/src/repositories/test-run.repository.ee.ts @@ -22,12 +22,24 @@ export class TestRunRepository extends Repository { super(TestRun, dataSource.manager); } - async createTestRun(workflowId: string): Promise { + async createTestRun( + workflowId: string, + attrs?: { + collectionId?: string | null; + workflowVersionId?: string | null; + evaluationConfigId?: string | null; + evaluationConfigSnapshot?: IDataObject | null; + }, + ): Promise { const testRun = this.create({ status: 'new', workflow: { id: workflowId, }, + collectionId: attrs?.collectionId ?? null, + workflowVersionId: attrs?.workflowVersionId ?? null, + evaluationConfigId: attrs?.evaluationConfigId ?? null, + evaluationConfigSnapshot: attrs?.evaluationConfigSnapshot ?? null, }); return await this.save(testRun); diff --git a/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/http-generic-auth-type-matches-prompt.test.ts b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/http-generic-auth-type-matches-prompt.test.ts new file mode 100644 index 00000000000..55ad7fe7a3d --- /dev/null +++ b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/http-generic-auth-type-matches-prompt.test.ts @@ -0,0 +1,139 @@ +import { httpGenericAuthTypeMatchesPrompt } from './http-generic-auth-type-matches-prompt'; +import type { WorkflowResponse } from '../../clients/n8n-client'; + +function workflowWithHttpRequest(parameters: Record): WorkflowResponse { + return { + id: 'wf-1', + name: 'HTTP auth test', + active: false, + nodes: [ + { + name: 'Manual Trigger', + type: 'n8n-nodes-base.manualTrigger', + parameters: {}, + }, + { + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + parameters, + }, + ], + connections: { + 'Manual Trigger': { + main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]], + }, + }, + }; +} + +describe('httpGenericAuthTypeMatchesPrompt', () => { + it('fails when prompt asks for Bearer but builder picks httpHeaderAuth', async () => { + const workflow = workflowWithHttpRequest({ + authentication: 'genericCredentialType', + genericAuthType: 'httpHeaderAuth', + }); + + const result = await httpGenericAuthTypeMatchesPrompt.run(workflow, { + prompt: 'Call the GitHub API using a Bearer token', + }); + + expect(result.pass).toBe(false); + expect(result.comment).toContain('HTTP Request'); + expect(result.comment).toContain('httpBearerAuth'); + }); + + it('fails when prompt mentions Authorization: Bearer header explicitly', async () => { + const workflow = workflowWithHttpRequest({ + authentication: 'genericCredentialType', + genericAuthType: 'httpHeaderAuth', + }); + + const result = await httpGenericAuthTypeMatchesPrompt.run(workflow, { + prompt: 'Send a request with Authorization: Bearer ', + }); + + expect(result.pass).toBe(false); + }); + + it('passes when builder picks httpBearerAuth for a Bearer prompt', async () => { + const workflow = workflowWithHttpRequest({ + authentication: 'genericCredentialType', + genericAuthType: 'httpBearerAuth', + }); + + const result = await httpGenericAuthTypeMatchesPrompt.run(workflow, { + prompt: 'Call the GitHub API using a Bearer token', + }); + + expect(result).toEqual({ pass: true }); + }); + + it('passes when prompt specifies a custom header name and builder picks httpHeaderAuth', async () => { + const workflow = workflowWithHttpRequest({ + authentication: 'genericCredentialType', + genericAuthType: 'httpHeaderAuth', + }); + + const result = await httpGenericAuthTypeMatchesPrompt.run(workflow, { + prompt: 'Call the Algolia API with an X-API-Key header', + }); + + expect(result).toEqual({ pass: true }); + }); + + it('passes when the prompt has no auth signal at all', async () => { + const workflow = workflowWithHttpRequest({ + authentication: 'genericCredentialType', + genericAuthType: 'httpHeaderAuth', + }); + + const result = await httpGenericAuthTypeMatchesPrompt.run(workflow, { + prompt: 'Fetch data from a public endpoint', + }); + + expect(result).toEqual({ pass: true }); + }); + + it('ignores HTTP Request nodes that do not use generic credential type', async () => { + const workflow = workflowWithHttpRequest({ + authentication: 'predefinedCredentialType', + nodeCredentialType: 'githubApi', + }); + + const result = await httpGenericAuthTypeMatchesPrompt.run(workflow, { + prompt: 'Call the GitHub API using a Bearer token', + }); + + expect(result).toEqual({ pass: true }); + }); + + it('ignores non-HTTP-Request nodes', async () => { + const workflow: WorkflowResponse = { + id: 'wf-2', + name: 'Slack only', + active: false, + nodes: [ + { + name: 'Manual Trigger', + type: 'n8n-nodes-base.manualTrigger', + parameters: {}, + }, + { + name: 'Slack', + type: 'n8n-nodes-base.slack', + parameters: { + authentication: 'genericCredentialType', + genericAuthType: 'httpHeaderAuth', + }, + }, + ], + connections: {}, + }; + + const result = await httpGenericAuthTypeMatchesPrompt.run(workflow, { + prompt: 'Send a Bearer-authenticated message', + }); + + expect(result).toEqual({ pass: true }); + }); +}); diff --git a/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/http-generic-auth-type-matches-prompt.ts b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/http-generic-auth-type-matches-prompt.ts new file mode 100644 index 00000000000..fd7fac4f06f --- /dev/null +++ b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/http-generic-auth-type-matches-prompt.ts @@ -0,0 +1,68 @@ +import type { WorkflowNodeResponse } from '../../clients/n8n-client'; +import type { BinaryCheck } from '../types'; + +const HTTP_REQUEST_TYPE = 'n8n-nodes-base.httpRequest'; + +const BEARER_PROMPT_PATTERNS = [ + /\bbearer\s+(?:token|auth(?:entication)?)\b/i, + /\bauthorization\s*:\s*bearer\b/i, + /\b(?:use|with|via|using)\s+(?:a\s+|my\s+)?bearer\b/i, +]; + +const CUSTOM_HEADER_NAME_PATTERNS = [ + /\bx-[a-z][a-z0-9-]*\b/i, + /\bheader\s+(?:named|called)\s+["'`]?[a-z][\w-]*["'`]?/i, + /\bcustom\s+header\b/i, + /\bheader\s+name\b/i, + /\bapikey\s+header\b/i, +]; + +function isHttpRequestWithGenericAuth(node: WorkflowNodeResponse): boolean { + return ( + node.type === HTTP_REQUEST_TYPE && node.parameters?.authentication === 'genericCredentialType' + ); +} + +function getGenericAuthType(node: WorkflowNodeResponse): string { + const value = node.parameters?.genericAuthType; + return typeof value === 'string' ? value : ''; +} + +function promptAsksForBearer(prompt: string): boolean { + return BEARER_PROMPT_PATTERNS.some((p) => p.test(prompt)); +} + +function promptSpecifiesCustomHeaderName(prompt: string): boolean { + return CUSTOM_HEADER_NAME_PATTERNS.some((p) => p.test(prompt)); +} + +export const httpGenericAuthTypeMatchesPrompt: BinaryCheck = { + name: 'http_generic_auth_type_matches_prompt', + description: + 'HTTP Request nodes pick a genericAuthType consistent with the prompt — prefer httpBearerAuth for Authorization: Bearer flows; reserve httpHeaderAuth for custom header names', + kind: 'deterministic', + run(workflow, ctx) { + const issues: string[] = []; + + for (const node of workflow.nodes ?? []) { + if (!isHttpRequestWithGenericAuth(node)) continue; + + const genericAuthType = getGenericAuthType(node); + if (genericAuthType !== 'httpHeaderAuth') continue; + + const bearerIntent = promptAsksForBearer(ctx.prompt); + const customHeaderIntent = promptSpecifiesCustomHeaderName(ctx.prompt); + + if (bearerIntent && !customHeaderIntent) { + issues.push( + `"${node.name}" uses httpHeaderAuth but the prompt asks for Bearer auth — should be httpBearerAuth`, + ); + } + } + + return { + pass: issues.length === 0, + ...(issues.length > 0 ? { comment: issues.join('; ') } : {}), + }; + }, +}; diff --git a/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/index.ts b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/index.ts index 8a5bfc9c17c..a03943c9ff2 100644 --- a/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/index.ts +++ b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/index.ts @@ -14,6 +14,7 @@ import { handlesMultipleItems } from './handles-multiple-items'; import { hasNodes } from './has-nodes'; import { hasStartNode } from './has-start-node'; import { hasTrigger } from './has-trigger'; +import { httpGenericAuthTypeMatchesPrompt } from './http-generic-auth-type-matches-prompt'; import { inboundTriggerAuthDefaults } from './inbound-trigger-auth-defaults'; import { memoryProperlyConnected } from './memory-properly-connected'; import { memorySessionKeyExpression } from './memory-session-key-expression'; @@ -50,6 +51,7 @@ export const DETERMINISTIC_CHECKS: BinaryCheck[] = [ toolsHaveParameters, noUnreachableNodes, inboundTriggerAuthDefaults, + httpGenericAuthTypeMatchesPrompt, validNodeConfig, ]; diff --git a/packages/@n8n/instance-ai/evaluations/cli/compare-pairwise.ts b/packages/@n8n/instance-ai/evaluations/cli/compare-pairwise.ts index fafbeb99db6..040e76d3cf9 100644 --- a/packages/@n8n/instance-ai/evaluations/cli/compare-pairwise.ts +++ b/packages/@n8n/instance-ai/evaluations/cli/compare-pairwise.ts @@ -17,6 +17,9 @@ import { jsonParse } from 'n8n-workflow'; import { promises as fs } from 'node:fs'; import path from 'node:path'; +import { scrubSecretsInText } from '../../src/utils/scrub-secrets'; +import { redactSecrets } from '../harness/redact'; + // --------------------------------------------------------------------------- // Shared shape after normalization // --------------------------------------------------------------------------- @@ -50,6 +53,8 @@ export interface BuilderRecord { toolCallErrors?: number; /** Total tool calls observed, used as the error-rate denominator. */ toolCallsTotal?: number; + /** Raw tool-call trace. IA-only — populated by `loadInstanceAiRun`. */ + toolCalls?: IAToolCallTrace[]; } interface BuilderSummary { @@ -190,6 +195,7 @@ async function loadInstanceAiRun(dir: string): Promise { submitCalls: tcs.filter((tc) => tc.toolName === 'submit-workflow').length, toolCallErrors: tcs.filter(isErroredIAToolCall).length, toolCallsTotal: tcs.length, + toolCalls: tcs, }; }); @@ -634,7 +640,7 @@ function renderBuilderColumn(label: string, record: BuilderRecord | undefined): } const errorBlock = record.errorMessage - ? `
${escapeHtml(record.errorMessage)}
` + ? `
${escapeHtml(scrubSecretsInText(record.errorMessage))}
` : ''; const idLine = record.exampleId @@ -654,12 +660,110 @@ function renderBuilderColumn(label: string, record: BuilderRecord | undefined): `; } -function renderRow(row: ComparisonRow, index: number): string { +interface SideLabels { + ee: string; + ia: string; +} + +function summarizeToolCallArgs(toolName: string, args: unknown): string { + if (!args || typeof args !== 'object') return ''; + // Redact secret-shaped keys (password / token / api_key / …) and scrub the + // extracted free-form strings for known credential patterns. The same + // summarized text is used as a data-attribute on the row, so any leaked + // secret would persist in the HTML and be searchable via filter input. + const a = redactSecrets(args) as Record; + const str = (v: unknown): string => (typeof v === 'string' ? scrubSecretsInText(v) : ''); + const trunc = (s: string, n = 160): string => (s.length > n ? s.slice(0, n) + '…' : s); + switch (toolName) { + case 'mastra_workspace_execute_command': + return trunc(str(a.command)); + case 'mastra_workspace_edit_file': + return trunc(str(a.path)); + case 'mastra_workspace_write_file': { + const p = str(a.path); + const len = typeof a.content === 'string' ? a.content.length : 0; + return `${p}${len ? ` (${len} chars)` : ''}`; + } + case 'mastra_workspace_read_file': { + const p = str(a.path); + const off = typeof a.offset === 'number' ? a.offset : undefined; + const lim = typeof a.limit === 'number' ? a.limit : undefined; + const range = off !== undefined || lim !== undefined ? ` @${off ?? 0}+${lim ?? '∞'}` : ''; + return trunc(`${p}${range}`); + } + case 'mastra_workspace_grep': + return trunc(`${str(a.pattern)}${a.path ? ` in ${str(a.path)}` : ''}`); + case 'mastra_workspace_lsp_inspect': { + const line = typeof a.line === 'number' ? String(a.line) : '?'; + return trunc(`${str(a.path)}:${line} ${str(a.match)}`); + } + case 'mastra_workspace_mkdir': + return trunc(str(a.path)); + case 'submit-workflow': + return trunc(`${str(a.name)} ${str(a.filePath)}`); + case 'verify-built-workflow': + return trunc(str(a.workflowId) || str(a.workItemId)); + case 'credentials': + case 'data-tables': + case 'nodes': + case 'workflows': + return trunc(str(a.action)); + default: + return trunc(scrubSecretsInText(JSON.stringify(a)), 120); + } +} + +function renderToolCallRows(traces: IAToolCallTrace[]): string { + return traces + .map((tc) => { + const errored = isErroredIAToolCall(tc); + const elapsed = tc.elapsedMs !== undefined ? formatDuration(tc.elapsedMs) : '—'; + const cls = errored ? 'tc-err' : ''; + const argSummary = summarizeToolCallArgs(tc.toolName, tc.args); + const argCell = argSummary ? `${escapeHtml(argSummary)}` : ''; + const detail = errored && tc.error ? escapeHtml(scrubSecretsInText(tc.error)) : ''; + return `${tc.step}${escapeHtml(tc.toolName)}${argCell}${elapsed}${detail}`; + }) + .join(''); +} + +function renderToolCallsColumn(label: string, record: BuilderRecord | undefined): string { + if (!record) { + return `
${escapeHtml(label)}
No record.
`; + } + const traces = record.toolCalls ?? []; + if (traces.length === 0) { + return `
${escapeHtml(label)} (0 calls)
No tool calls captured.
`; + } + const errCount = traces.filter(isErroredIAToolCall).length; + const errBadge = errCount > 0 ? ` · ${errCount} err` : ''; + return `
+
${escapeHtml(label)} (${traces.length} call${traces.length === 1 ? '' : 's'}${errBadge})
+ + + ${renderToolCallRows(traces)} +
#ToolArgsTimeError
+
`; +} + +function renderToolCallsSection(row: ComparisonRow, labels: SideLabels): string { + const anyTraces = (row.ee?.toolCalls?.length ?? 0) > 0 || (row.ia?.toolCalls?.length ?? 0) > 0; + if (!anyTraces) return ''; + return `
+ Tool calls +
+ ${renderToolCallsColumn(labels.ee, row.ee)} + ${renderToolCallsColumn(labels.ia, row.ia)} +
+
`; +} + +function renderRow(row: ComparisonRow, index: number, labels: SideLabels): string { const verdictLabel: Record = { 'both-pass': 'BOTH PASS', 'both-fail': 'BOTH FAIL', - 'ee-only': 'CODE ONLY', - 'ia-only': 'IA ONLY', + 'ee-only': `${labels.ee.toUpperCase()} ONLY`, + 'ia-only': `${labels.ia.toUpperCase()} ONLY`, neither: '—', }; const verdictCls: Record = { @@ -690,14 +794,33 @@ function renderRow(row: ComparisonRow, index: number): string { // Heavy content (workflow previews + judge tables) is wrapped in a -
+
+ messages and rendered here so they always sit at the bottom + of the conversation. -->
- +
+ +
+ +
+
- - - - -
- - -
-
-
+ + + + +
+ + +
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue index 3b31e8a5ba1..bc3d5346d92 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue @@ -40,6 +40,7 @@ function handleSidebarResize({ width }: { width: number }) { provide(SidebarStateKey, { collapsed: sidebarCollapsed, + width: sidebarWidth, toggle: toggleSidebarCollapse, }); @@ -128,7 +129,7 @@ onUnmounted(() => { display: flex; height: 100%; width: 100%; - min-width: 900px; + min-width: 0; overflow: hidden; position: relative; z-index: 0; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiArtifactsPanel.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiArtifactsPanel.test.ts new file mode 100644 index 00000000000..e433e2d38a3 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiArtifactsPanel.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent } from '@testing-library/vue'; +import { defineComponent, h, nextTick, reactive } from 'vue'; +import { createComponentRenderer } from '@/__tests__/render'; +import type { TaskList } from '@n8n/api-types'; +import type { ResourceEntry } from '../useResourceRegistry'; +import InstanceAiArtifactsPanel from '../components/InstanceAiArtifactsPanel.vue'; + +const storeState = reactive({ + currentTasks: undefined as TaskList | undefined, + producedArtifacts: new Map(), +}); + +vi.mock('../instanceAi.store', () => ({ + useThread: vi.fn(() => storeState), +})); + +const renderComponent = createComponentRenderer(InstanceAiArtifactsPanel, { + global: { + stubs: { + ConnectionsCard: defineComponent({ + props: { + dropdownPortalTarget: { type: HTMLElement, required: false }, + }, + setup(props) { + return () => + h('section', { + 'data-test-id': 'connections-card', + 'data-portal-target-tag': props.dropdownPortalTarget?.tagName ?? '', + }); + }, + }), + }, + }, +}); + +describe('InstanceAiArtifactsPanel', () => { + beforeEach(() => { + storeState.currentTasks = undefined; + storeState.producedArtifacts = new Map(); + }); + + it('keeps empty artifacts and connections sections visible without an empty tasks section', () => { + const { getByText, getByTestId, queryByText } = renderComponent(); + + expect(getByTestId('instance-ai-artifacts-sidebar')).toBeInTheDocument(); + expect(getByTestId('instance-ai-artifacts-sidebar-group')).toBeInTheDocument(); + expect(getByTestId('instance-ai-artifacts-sidebar-pin')).toHaveAttribute( + 'aria-label', + 'Unpin panel', + ); + expect(getByTestId('instance-ai-artifacts-sidebar-pin')).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(getByText('No artifacts yet')).toBeInTheDocument(); + expect(queryByText('To-do list')).not.toBeInTheDocument(); + expect(queryByText('No tasks yet')).not.toBeInTheDocument(); + expect(getByTestId('connections-card')).toBeInTheDocument(); + }); + + it('anchors connection menus inside the panel', async () => { + const { getByTestId } = renderComponent(); + + await nextTick(); + + expect(getByTestId('connections-card')).toHaveAttribute('data-portal-target-tag', 'ASIDE'); + }); + + it('emits when the pin button is clicked and reflects the unpinned label', async () => { + const { emitted, getByTestId } = renderComponent({ props: { isPinned: false } }); + const pinButton = getByTestId('instance-ai-artifacts-sidebar-pin'); + + expect(pinButton).toHaveAttribute('aria-label', 'Pin panel'); + expect(pinButton).toHaveAttribute('aria-pressed', 'false'); + + await fireEvent.click(pinButton); + + expect(emitted('togglePinned')).toHaveLength(1); + }); + + it('hides the pin button when pinning is unavailable', () => { + const { queryByTestId } = renderComponent({ props: { isPinningAvailable: false } }); + + expect(queryByTestId('instance-ai-artifacts-sidebar-pin')).not.toBeInTheDocument(); + }); + + it('opens artifacts in preview and shows tasks without progress counts', async () => { + const openWorkflowPreview = vi.fn(); + storeState.producedArtifacts = new Map([ + [ + 'wf-1', + { + type: 'workflow', + id: 'wf-1', + name: 'Sales follow-up workflow', + }, + ], + ]); + storeState.currentTasks = { + tasks: [ + { id: 'task-1', description: 'Build the workflow', status: 'done' }, + { id: 'task-2', description: 'Review the workflow', status: 'in_progress' }, + ], + }; + + const { getByRole, getByText, queryByText } = renderComponent({ + global: { + provide: { + openWorkflowPreview, + }, + }, + }); + + const artifactLink = getByRole('link', { name: 'Open Sales follow-up workflow' }); + expect(artifactLink).toHaveAttribute('href', '/workflow/wf-1'); + expect(getByText('To-do list')).toBeInTheDocument(); + expect(getByText('Build the workflow')).toBeInTheDocument(); + expect(queryByText('1/2')).not.toBeInTheDocument(); + + await fireEvent.click(artifactLink); + + expect(openWorkflowPreview).toHaveBeenCalledWith('wf-1'); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPreviewTabBar.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPreviewTabBar.test.ts index 650fde789aa..6b91dd2aaf3 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPreviewTabBar.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPreviewTabBar.test.ts @@ -36,15 +36,20 @@ const Wrapper = defineComponent({ props: { tabs: { type: Array as () => ArtifactTab[], required: true }, activeTabId: { type: String, default: undefined }, + isExpanded: { type: Boolean, default: false }, + previewToggleLabel: { type: String, default: undefined }, }, - emits: ['close'], + emits: ['togglePreview', 'toggleExpanded'], setup(props, { emit }) { return () => h(TabsRoot, { modelValue: props.activeTabId }, () => h(InstanceAiPreviewTabBar, { tabs: props.tabs, activeTabId: props.activeTabId, - onClose: () => emit('close'), + isExpanded: props.isExpanded, + previewToggleLabel: props.previewToggleLabel, + onTogglePreview: () => emit('togglePreview'), + onToggleExpanded: () => emit('toggleExpanded'), }), ); }, @@ -83,17 +88,49 @@ describe('InstanceAiPreviewTabBar', () => { expect(inactive?.getAttribute('data-state')).toBe('inactive'); }); - it('emits close when the collapse button is clicked', async () => { + it('emits toggleExpanded when the expand button is clicked', async () => { const { container, emitted } = renderComponent({ props: { tabs: [workflowTab], activeTabId: 'wf-1' }, }); - const collapseButton = container.querySelector( - '[data-test-id="instance-ai-preview-close"]', + const expandButton = container.querySelector( + '[data-test-id="instance-ai-preview-expand-toggle"]', ); - expect(collapseButton).not.toBeNull(); - await fireEvent.click(collapseButton!); + expect(expandButton).not.toBeNull(); + expect(expandButton).toHaveAttribute('aria-label', 'Expand panel'); + await fireEvent.click(expandButton!); - expect(emitted().close).toBeTruthy(); + expect(emitted().toggleExpanded).toBeTruthy(); + }); + + it('emits togglePreview when the preview toggle is clicked', async () => { + const { getByTestId, emitted } = renderComponent({ + props: { + tabs: [workflowTab], + activeTabId: 'wf-1', + previewToggleLabel: 'Hide artifacts preview', + }, + }); + + const toggleButton = getByTestId('instance-ai-artifacts-preview-toggle'); + expect(toggleButton).toHaveAttribute('aria-label', 'Hide artifacts preview'); + expect(toggleButton).toHaveAttribute('aria-pressed', 'true'); + + await fireEvent.click(toggleButton); + + expect(emitted().togglePreview).toBeTruthy(); + }); + + it('labels the size toggle as collapse when the panel is expanded', () => { + const { container } = renderComponent({ + props: { tabs: [workflowTab], activeTabId: 'wf-1', isExpanded: true }, + }); + + const collapseButton = container.querySelector( + '[data-test-id="instance-ai-preview-expand-toggle"]', + ); + + expect(collapseButton).not.toBeNull(); + expect(collapseButton).toHaveAttribute('aria-label', 'Collapse panel'); }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts index 75f49df77cc..17abb2b9093 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts @@ -9,6 +9,10 @@ import { useInstanceAiStore, type ThreadRuntime } from '../instanceAi.store'; import { usePushConnectionStore } from '@/app/stores/pushConnection.store'; import { SidebarStateKey } from '../instanceAiLayout'; +const mockWindowSizeState = vi.hoisted(() => ({ + width: { value: 1200 }, +})); + vi.mock('@/app/composables/usePageRedirectionHelper', () => ({ usePageRedirectionHelper: () => ({ goToUpgrade: vi.fn() }), })); @@ -30,7 +34,7 @@ vi.mock('vue-router', async (importOriginal) => ({ vi.mock('@vueuse/core', async (importOriginal) => ({ ...(await importOriginal()), useScroll: () => ({ arrivedState: { bottom: true } }), - useWindowSize: () => ({ width: ref(1200) }), + useWindowSize: () => ({ width: mockWindowSizeState.width }), })); const InstanceAiInputStub = defineComponent({ @@ -73,6 +77,7 @@ describe('InstanceAiThreadView', () => { thread = { id: 'thread-1', messages: [], + hasMessages: false, sseState: 'connected', isStreaming: false, isSendingMessage: false, @@ -106,6 +111,7 @@ describe('InstanceAiThreadView', () => { updatedAt: '2026-04-01T00:00:00.000Z', }, ] as typeof store.threads; + mockWindowSizeState.width.value = 1200; // `useExecutionPushEvents` (consumed by ThreadView) registers a push // listener and stores the returned removeListener; it gets invoked on @@ -166,4 +172,25 @@ describe('InstanceAiThreadView', () => { }); expect(thread.loadHistoricalMessages).toHaveBeenCalledWith(); }); + + it('uses edge reveal when the viewport is too narrow for pinned artifacts', async () => { + mockWindowSizeState.width.value = 900; + thread.messages = [ + { + id: 'msg-1', + role: 'assistant', + content: 'already loaded', + isStreaming: false, + createdAt: '2026-04-01T00:00:00.000Z', + }, + ] as typeof thread.messages; + Object.defineProperty(thread, 'hasMessages', { value: true, configurable: true }); + + const { getByTestId, queryByTestId } = renderView({ props: { threadId: 'thread-1' } }); + + await vi.waitFor(() => { + expect(getByTestId('instance-ai-artifacts-sidebar-edge')).toBeInTheDocument(); + }); + expect(queryByTestId('instance-ai-artifacts-sidebar-slot')).not.toBeInTheDocument(); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionRow.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionRow.vue index 94fa513ba95..676fe40cfae 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionRow.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionRow.vue @@ -13,6 +13,7 @@ const props = defineProps<{ icon: IconName; status: ConnectionStatus; actions: RowAction[]; + dropdownPortalTarget?: HTMLElement; }>(); const emit = defineEmits<{ @@ -77,6 +78,7 @@ function handleSelect(action: RowAction) { v-if="menuItems.length > 0" :items="menuItems" placement="bottom-end" + :portal-target="dropdownPortalTarget" @select="handleSelect" />
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue index cfb4efeac22..d08c5aafe56 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue @@ -19,8 +19,11 @@ const i18n = useI18n(); const uiStore = useUIStore(); const store = useInstanceAiSettingsStore(); -const connections = computed(() => store.connections); +const props = defineProps<{ + dropdownPortalTarget?: HTMLElement; +}>(); +const connections = computed(() => store.connections); const isVisible = computed( () => !store.isLocalGatewayDisabledByAdmin && @@ -90,19 +93,21 @@ async function handleRemove(type: ConnectionType) {