diff --git a/.changeset/resolve-parameter-ui-only.md b/.changeset/resolve-parameter-ui-only.md new file mode 100644 index 0000000000000..a25cf237416db --- /dev/null +++ b/.changeset/resolve-parameter-ui-only.md @@ -0,0 +1,5 @@ +--- +'@n8n/editor-ui': minor +--- + +Resolve `$parameter[...]` expressions in the Node Details View when no execution data is available. Falls back to `activeNode.parameters` so values can be previewed without running a workflow. Includes unit tests. diff --git a/.github/workflows/test-docker-build.yml b/.github/workflows/test-docker-build.yml new file mode 100644 index 0000000000000..979a938772ac2 --- /dev/null +++ b/.github/workflows/test-docker-build.yml @@ -0,0 +1,258 @@ +name: "Test & (optionally) Build Docker Image" + +on: + pull_request: + branches: [ master ] + types: [opened, synchronize, reopened, closed] + workflow_dispatch: + inputs: + build_type: + description: 'Type of build' + required: true + default: 'test' + type: choice + options: [ test, full ] + schedule: + - cron: "0 3 * * 0" # Weekly at 03:00 UTC + +permissions: + contents: read + packages: delete + +jobs: + test: + if: github.event.action != 'closed' + name: Lint, Typecheck & Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + CI: true + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + + steps: + - name: Checkout + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # actions/checkout@v4.2.2 + + - name: Setup pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # pnpm/action-setup@v4 + with: + version: 10.12.1 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # actions/setup-node@v4.4.0 + with: + node-version: '22.x' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm -w lint + + - name: Typecheck + run: pnpm -w typecheck + + - name: Show workspace packages + run: pnpm -w list --depth -1 + + - name: Run editor-ui unit tests + run: pnpm -w test --filter n8n-editor-ui -- --run --reporter=dot + + build-image: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: test + if: > + (github.event_name != 'pull_request' || github.event.action != 'closed') && + (github.event_name == 'pull_request' || github.event.inputs.build_type == 'full') + + steps: + - name: Checkout + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # actions/checkout@v4.2.2 + + - name: Set up pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # pnpm/action-setup@v4 + with: + version: 10.12.1 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # actions/setup-node@v4.4.0 + with: + node-version: '22.x' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build n8n + run: pnpm build:n8n + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # docker/setup-buildx-action@v3.11.1 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # docker/login-action@v3.5.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (unique tags incl. PR/branch) + id: meta + uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # docker/metadata-action@v5.8.0 + with: + images: ghcr.io/${{ github.repository_owner }}/n8n + tags: | + type=raw,value=test-build + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=pr,prefix=pr- + type=ref,event=branch + type=sha,format=short,prefix={{branch}}- + + - name: Compute primary test tag + id: picktag + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "tag=pr-${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" + else + echo "tag=test-build" >> "$GITHUB_OUTPUT" + fi + + - name: Build and push Docker image + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # docker/build-push-action@v6.18.0 + with: + context: . + file: ./docker/images/n8n/Dockerfile + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + push: true + load: false + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + + - name: Smoke-test container (strict health check + logs) + shell: bash + run: | + set -Eeuo pipefail + + IMAGE="ghcr.io/${{ github.repository_owner }}/n8n:${{ steps.picktag.outputs.tag }}" + CNAME="n8n-test-${GITHUB_RUN_ID}" + + echo "::group::Pull image" + docker pull "$IMAGE" + echo "::endgroup::" + + echo "::group::Sanity: version/help" + docker run --rm "$IMAGE" --version + docker run --rm "$IMAGE" --help >/dev/null + echo "::endgroup::" + + echo "Starting $CNAME..." + docker run -d --name "$CNAME" -p 5678:5678 "$IMAGE" > /dev/null + + # Always try to clean up; never fail the job because cleanup failed + cleanup() { docker rm -f "$CNAME" >/dev/null 2>&1 || true; } + trap cleanup EXIT + + echo "Waiting up to 120s for /healthz..." + for i in {1..60}; do + if curl -fsS http://localhost:5678/healthz >/dev/null; then + echo "✅ Healthy after $((i*2))s" + break + fi + sleep 2 + done + + # Hard fail if still not healthy; show logs to make it actionable + if ! curl -fsS http://localhost:5678/healthz >/dev/null; then + echo "❌ Health check failed. Recent logs:" + docker logs --tail 200 --timestamps "$CNAME" || true + echo "Inspect state:" + docker inspect "$CNAME" || true + exit 1 + fi + + echo "✅ Container passed health check." + + + - name: Show image info and pull instructions + run: | + echo "🎉 Docker image successfully built and pushed!" + echo "" + echo "📦 Useful tags:" + echo " - ghcr.io/${{ github.repository_owner }}/n8n:${{ steps.picktag.outputs.tag }}" + echo " - ghcr.io/${{ github.repository_owner }}/n8n:test-build" + echo " - ghcr.io/${{ github.repository_owner }}/n8n:latest" + echo " - ghcr.io/${{ github.repository_owner }}/n8n:master-${{ github.sha }}" + echo "" + echo "🚀 Pull & run:" + echo " docker pull ghcr.io/${{ github.repository_owner }}/n8n:${{ steps.picktag.outputs.tag }}" + echo " docker run -p 5678:5678 ghcr.io/${{ github.repository_owner }}/n8n:${{ steps.picktag.outputs.tag }}" + + cleanup-pr: + name: Cleanup PR Image + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && github.event.action == 'closed' + steps: + - name: Delete GHCR image tag for PR + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="pr-${{ github.event.pull_request.number }}" + IMAGE="${{ github.repository_owner }}/n8n" + echo "Fetching manifest digest for $IMAGE:$TAG from GHCR..." + DIGEST=$(curl -sI \ + -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + -H "Authorization: Bearer $GHCR_TOKEN" \ + "https://ghcr.io/v2/${IMAGE}/manifests/${TAG}" \ + | grep -i 'docker-content-digest:' | awk '{print $2}' | tr -d $'\r') + if [ -z "$DIGEST" ] || [ "$DIGEST" = "null" ]; then + echo "❌ Could not find digest for tag $TAG. Tag may not exist." + exit 0 + fi + echo "Found digest: $DIGEST" + echo "Deleting $IMAGE@$DIGEST..." + curl -s -X DELETE \ + -H "Authorization: Bearer $GHCR_TOKEN" \ + "https://ghcr.io/v2/${IMAGE}/manifests/${DIGEST}" && \ + echo "✅ Deleted $IMAGE:$TAG successfully." + + cleanup-orphans: + name: Scheduled Cleanup of Orphaned PR Images + runs-on: ubuntu-latest + if: github.event_name == 'schedule' + steps: + - name: List and delete orphaned PR tags from GHCR + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + IMAGE="${{ github.repository_owner }}/n8n" + echo "Fetching tags for $IMAGE..." + TAGS=$(curl -s \ + -H "Authorization: Bearer $GHCR_TOKEN" \ + "https://ghcr.io/v2/${IMAGE}/tags/list" | jq -r '.tags[]' | grep '^pr-' || true) + if [ -z "$TAGS" ]; then + echo "No orphaned pr-* tags found." + exit 0 + fi + echo "Found tags:" + echo "$TAGS" + for TAG in $TAGS; do + echo "Fetching digest for $TAG..." + DIGEST=$(curl -sI \ + -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + -H "Authorization: Bearer $GHCR_TOKEN" \ + "https://ghcr.io/v2/${IMAGE}/manifests/${TAG}" \ + | grep -i 'docker-content-digest:' | awk '{print $2}' | tr -d $'\r') + if [ -n "$DIGEST" ] && [ "$DIGEST" != "null" ]; then + echo "Deleting $IMAGE@$DIGEST..." + curl -s -X DELETE \ + -H "Authorization: Bearer $GHCR_TOKEN" \ + "https://ghcr.io/v2/${IMAGE}/manifests/${DIGEST}" && \ + echo "✅ Deleted $IMAGE:$TAG" + else + echo "Skipping $TAG — digest not found." + fi + done diff --git a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.test.ts b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.test.ts index e450960e9d3a8..552af5ac6e240 100644 --- a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.test.ts +++ b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.test.ts @@ -1,6 +1,6 @@ -import type { IExecutionResponse, IWorkflowDb } from '@/Interface'; +import type { IExecutionResponse, INodeUi, IWorkflowDb } from '@/Interface'; import type { WorkflowData } from '@n8n/rest-api-client/api/workflows'; -import { resolveParameter, useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; +import { resolveParameter, useWorkflowHelpers, __test__ } from '@/composables/useWorkflowHelpers'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import { useWorkflowsStore } from '@/stores/workflows.store'; @@ -1041,3 +1041,66 @@ describe(resolveParameter, () => { }); }); }); + +// +// UI-only $parameter[...] resolution tests +// +describe('UI-only $parameter[...] resolution', () => { + const { resolveParameterFromUiContext } = __test__; + + function makeActiveNode(parameters: Record) { + return { + id: '1', + name: 'Test Node', + type: 'n8n-nodes-base.test', + typeVersion: 1, + position: [0, 0], + parameters, + } as INodeUi; + } + + it('returns the parameter value for {{ $parameter["key"] }}', () => { + const activeNode = makeActiveNode({ path: 'foo', count: 42 }); + expect(resolveParameterFromUiContext('{{ $parameter["path"] }}', activeNode)).toBe('foo'); + }); + + it('preserves non-string values (e.g., number)', () => { + const activeNode = makeActiveNode({ count: 42 }); + expect(resolveParameterFromUiContext('{{ $parameter["count"] }}', activeNode)).toBe(42); + }); + + it('supports surrounding whitespace inside {{ }}', () => { + const activeNode = makeActiveNode({ path: 'foo' }); + expect(resolveParameterFromUiContext('{{ $parameter["path"] }}', activeNode)).toBe('foo'); + }); + + it('returns null when activeNode is null', () => { + expect(resolveParameterFromUiContext('{{ $parameter["anything"] }}', null)).toBeNull(); + }); + + it('returns null when parameter is not a string', () => { + const activeNode = makeActiveNode({ path: 'foo' }); + expect(resolveParameterFromUiContext(123, activeNode)).toBeNull(); + }); + + it('returns null when expression is not a {{ $parameter[...] }} expression', () => { + const activeNode = makeActiveNode({ path: 'foo' }); + expect(resolveParameterFromUiContext('={{$json["path"]}}', activeNode)).toBeNull(); + }); + + it('returns null when the referenced key is missing', () => { + const activeNode = makeActiveNode({ path: 'foo' }); + expect(resolveParameterFromUiContext('{{ $parameter["missing"] }}', activeNode)).toBeNull(); + }); + + it('does not match embedded usage (must be a pure expression)', () => { + const activeNode = makeActiveNode({ path: 'foo' }); + expect(resolveParameterFromUiContext('prefix {{ $parameter["path"] }} suffix', activeNode)).toBeNull(); + }); + + it('returns object values when the key exists', () => { + const activeNode = makeActiveNode({ cfg: { url: 'x' } }); + expect(resolveParameterFromUiContext<{ url: string } | null>('{{ $parameter["cfg"] }}', activeNode)) + .toEqual({ url: 'x' }); + }); +}); diff --git a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts index e4b8c9a9ad8bb..890ed0627be12 100644 --- a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts +++ b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts @@ -65,22 +65,18 @@ import { useWorkflowsEEStore } from '@/stores/workflows.ee.store'; import { findWebhook } from '@n8n/rest-api-client/api/webhooks'; import type { ExpressionLocalResolveContext } from '@/types/expressions'; -export type ResolveParameterOptions = { - targetItem?: TargetItem; - inputNodeName?: string; - inputRunIndex?: number; - inputBranchIndex?: number; - additionalKeys?: IWorkflowDataProxyAdditionalKeys; - isForCredential?: boolean; - contextNodeName?: string; - connections?: IConnections; -}; +// Type guard to discriminate ExpressionLocalResolveContext at runtime +function isExpressionLocalResolveContext( + value: ResolveParameterOptions | ExpressionLocalResolveContext | undefined, +): value is ExpressionLocalResolveContext { + return !!value && typeof value === 'object' && 'localResolve' in value && value.localResolve === true; +} export function resolveParameter( parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], - opts: ResolveParameterOptions | ExpressionLocalResolveContext = {}, + opts?: ResolveParameterOptions | ExpressionLocalResolveContext, ): T | null { - if ('localResolve' in opts && opts.localResolve) { + if (isExpressionLocalResolveContext(opts)) { return resolveParameterImpl( parameter, opts.workflow, @@ -101,6 +97,9 @@ export function resolveParameter( const workflowsStore = useWorkflowsStore(); + // Narrow to ResolveParameterOptions without casting + const options: ResolveParameterOptions = opts ?? {}; + return resolveParameterImpl( parameter, workflowsStore.workflowObject as Workflow, @@ -110,10 +109,28 @@ export function resolveParameter( workflowsStore.workflowExecutionData, workflowsStore.shouldReplaceInputDataWithPinData, workflowsStore.pinnedWorkflowData, - opts, + options, ); } +/** + * UI-only helper to preview a `{{ $parameter["..."] }}` expression from `activeNode.parameters` + * when no execution data is available. Returns `null` if not a pure `$parameter[...]` expression. + */ +function resolveParameterFromUiContext( + parameter: NodeParameterValue, + activeNode: INodeUi | null, +): NodeParameterValue | null { + if (!activeNode || typeof parameter !== 'string') return null; + + // Require the entire string to be exactly a {{ $parameter["..."] }} expression + const m = parameter.match(/^\s*\{\{\s*\$parameter\["(.+?)"\]\s*\}\}\s*$/); + if (!m) return null; + + const value = (activeNode.parameters ?? {})[m[1]]; + return value === undefined ? null : value; +} + // TODO: move to separate file function resolveParameterImpl( parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], @@ -124,8 +141,14 @@ function resolveParameterImpl( executionData: IExecutionResponse | null, shouldReplaceInputDataWithPinData: boolean, pinData: IPinData | undefined, - opts: ResolveParameterOptions = {}, + opts: ResolveParameterOptions = {}, ): T | null { + if (!executionData && opts.uiPreviewParamOnly === true && typeof parameter !== 'object') { + const uiVal = resolveParameterFromUiContext(parameter, ndvActiveNode); + if (uiVal !== null && (typeof opts.uiPreviewGuard !== 'function' || opts.uiPreviewGuard(uiVal))) { + return uiVal; + } + } let itemIndex = opts?.targetItem?.itemIndex || 0; const additionalKeys: IWorkflowDataProxyAdditionalKeys = { @@ -463,11 +486,11 @@ function executeDataImpl( ): IExecuteData { const connectionsByDestinationNode = workflowUtils.mapConnectionsByDestination(connections); - const executeData = { + const executeData: IExecuteData = { node: {}, data: {}, source: null, - } as IExecuteData; + }; parentRunIndex = parentRunIndex ?? runIndex; @@ -495,8 +518,8 @@ function executeDataImpl( !workflowRunData[parentNodeName] || workflowRunData[parentNodeName].length <= parentRunIndex || !workflowRunData[parentNodeName][parentRunIndex] || - !workflowRunData[parentNodeName][parentRunIndex].hasOwnProperty('data') || - !workflowRunData[parentNodeName][parentRunIndex].data?.hasOwnProperty(inputName) + !Object.prototype.hasOwnProperty.call(workflowRunData[parentNodeName][parentRunIndex], 'data') || + !Object.prototype.hasOwnProperty.call(workflowRunData[parentNodeName][parentRunIndex].data ?? {}, inputName) ) { executeData.data = {}; } else { @@ -825,7 +848,7 @@ export function useWorkflowHelpers() { let resolved; try { resolved = resolveExpression(value, undefined, { isForCredential: false }); - } catch (error) { + } catch (error: any) { resolved = `Error in expression: "${error.message}"`; } newObj[key] = { @@ -1095,3 +1118,8 @@ export function useWorkflowHelpers() { checkConflictingWebhooks, }; } + +/** Test-only hook (non-public) */ +export const __test__ = { + resolveParameterFromUiContext, +};