Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/resolve-parameter-ui-only.md
Original file line number Diff line number Diff line change
@@ -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.
258 changes: 258 additions & 0 deletions .github/workflows/test-docker-build.yml
Original file line number Diff line number Diff line change
@@ -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/[email protected]

- 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/[email protected]
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/[email protected]

- 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/[email protected]
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/[email protected]

- name: Log in to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # docker/[email protected]
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/[email protected]
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/[email protected]
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
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -1041,3 +1041,66 @@ describe(resolveParameter, () => {
});
});
});

//
// UI-only $parameter[...] resolution tests
//
describe('UI-only $parameter[...] resolution', () => {
const { resolveParameterFromUiContext } = __test__;

function makeActiveNode(parameters: Record<string, unknown>) {
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<string>('{{ $parameter["path"] }}', activeNode)).toBe('foo');
});

it('preserves non-string values (e.g., number)', () => {
const activeNode = makeActiveNode({ count: 42 });
expect(resolveParameterFromUiContext<number>('{{ $parameter["count"] }}', activeNode)).toBe(42);
});

it('supports surrounding whitespace inside {{ }}', () => {
const activeNode = makeActiveNode({ path: 'foo' });
expect(resolveParameterFromUiContext<string>('{{ $parameter["path"] }}', activeNode)).toBe('foo');
});

it('returns null when activeNode is null', () => {
expect(resolveParameterFromUiContext<string>('{{ $parameter["anything"] }}', null)).toBeNull();
});

it('returns null when parameter is not a string', () => {
const activeNode = makeActiveNode({ path: 'foo' });
expect(resolveParameterFromUiContext<string>(123, activeNode)).toBeNull();
});

it('returns null when expression is not a {{ $parameter[...] }} expression', () => {
const activeNode = makeActiveNode({ path: 'foo' });
expect(resolveParameterFromUiContext<string>('={{$json["path"]}}', activeNode)).toBeNull();
});

it('returns null when the referenced key is missing', () => {
const activeNode = makeActiveNode({ path: 'foo' });
expect(resolveParameterFromUiContext<string>('{{ $parameter["missing"] }}', activeNode)).toBeNull();
});

it('does not match embedded usage (must be a pure expression)', () => {
const activeNode = makeActiveNode({ path: 'foo' });
expect(resolveParameterFromUiContext<string>('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' });
});
});
Loading