Merge branch 'master' into ADO-3066-show-errors-in-rlc

This commit is contained in:
Milorad FIlipović 2025-03-05 14:16:28 +01:00
commit c09bfa0940
41 changed files with 1475 additions and 356 deletions

View file

@ -109,7 +109,6 @@ jobs:
context: ./docker/images/n8n
build-args: |
N8N_VERSION=${{ needs.publish-to-npm.outputs.release }}
N8N_RELEASE_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
platforms: linux/amd64,linux/arm64
provenance: false
push: true

View file

@ -4,18 +4,15 @@ FROM n8nio/base:${NODE_VERSION}
ARG N8N_VERSION
RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" ; exit 1; fi
ARG N8N_RELEASE_DATE
LABEL org.opencontainers.image.title="n8n"
LABEL org.opencontainers.image.description="Workflow Automation Tool"
LABEL org.opencontainers.image.source="https://github.com/n8n-io/n8n"
LABEL org.opencontainers.image.url="https://n8n.io"
LABEL org.opencontainers.image.version=${N8N_VERSION}
LABEL org.opencontainers.image.created=${N8N_RELEASE_DATE}
ENV N8N_VERSION=${N8N_VERSION}
ENV NODE_ENV=production
ENV N8N_RELEASE_TYPE=stable
ENV N8N_RELEASE_DATE=${N8N_RELEASE_DATE}
RUN set -eux; \
npm install -g --omit=dev n8n@${N8N_VERSION} --ignore-scripts && \
npm rebuild --prefix=/usr/local/lib/node_modules/n8n sqlite3 && \

View file

@ -9,9 +9,6 @@ export class GenericConfig {
@Env('N8N_RELEASE_TYPE')
releaseChannel: 'stable' | 'beta' | 'nightly' | 'dev' = 'dev';
@Env('N8N_RELEASE_DATE')
releaseDate?: Date;
/** Grace period (in seconds) to wait for components to shut down before process exit. */
@Env('N8N_GRACEFUL_SHUTDOWN_TIMEOUT')
gracefulShutdownTimeout: number = 30;

View file

@ -10,14 +10,6 @@ export class SentryConfig {
@Env('N8N_FRONTEND_SENTRY_DSN')
frontendDsn: string = '';
/**
* Version of the n8n instance
*
* @example '1.73.0'
*/
@Env('N8N_VERSION')
n8nVersion: string = '';
/**
* Environment of the n8n instance.
*

View file

@ -243,7 +243,6 @@ describe('GlobalConfig', () => {
sentry: {
backendDsn: '',
frontendDsn: '',
n8nVersion: '',
environment: '',
deploymentName: '',
},
@ -326,7 +325,6 @@ describe('GlobalConfig', () => {
DB_LOGGING_MAX_EXECUTION_TIME: '0',
N8N_METRICS: 'TRUE',
N8N_TEMPLATES_ENABLED: '0',
N8N_RELEASE_DATE: '2025-02-17T13:54:15Z',
};
const config = Container.get(GlobalConfig);
expect(structuredClone(config)).toEqual({
@ -358,10 +356,6 @@ describe('GlobalConfig', () => {
...defaultConfig.templates,
enabled: false,
},
generic: {
...defaultConfig.generic,
releaseDate: new Date('2025-02-17T13:54:15.000Z'),
},
});
expect(mockFs.readFileSync).not.toHaveBeenCalled();
});
@ -397,15 +391,4 @@ describe('GlobalConfig', () => {
'Invalid number value for DB_LOGGING_MAX_EXECUTION_TIME: abcd',
);
});
it('should handle invalid timestamps', () => {
process.env = {
N8N_RELEASE_DATE: 'abcd',
};
const config = Container.get(GlobalConfig);
expect(config.generic.releaseDate).toBeUndefined();
expect(consoleWarnMock).toHaveBeenCalledWith(
'Invalid timestamp value for N8N_RELEASE_DATE: abcd',
);
});
});

View file

@ -14,7 +14,13 @@ import { ensureError, sleep, UserError } from 'n8n-workflow';
import type { AbstractServer } from '@/abstract-server';
import config from '@/config';
import { LICENSE_FEATURES, inDevelopment, inTest } from '@/constants';
import {
LICENSE_FEATURES,
N8N_VERSION,
N8N_RELEASE_DATE,
inDevelopment,
inTest,
} from '@/constants';
import * as CrashJournal from '@/crash-journal';
import * as Db from '@/db';
import { getDataDeduplicationService } from '@/deduplication';
@ -63,15 +69,14 @@ export abstract class BaseCommand extends Command {
async init(): Promise<void> {
this.errorReporter = Container.get(ErrorReporter);
const { releaseDate } = this.globalConfig.generic;
const { backendDsn, n8nVersion, environment, deploymentName } = this.globalConfig.sentry;
const { backendDsn, environment, deploymentName } = this.globalConfig.sentry;
await this.errorReporter.init({
serverType: this.instanceSettings.instanceType,
dsn: backendDsn,
environment,
release: n8nVersion,
release: N8N_VERSION,
serverName: deploymentName,
releaseDate,
releaseDate: N8N_RELEASE_DATE,
});
initExpressionEvaluator();

View file

@ -1,4 +1,4 @@
import { readFileSync } from 'fs';
import { readFileSync, statSync } from 'fs';
import type { n8n } from 'n8n-core';
import type { ITaskDataConnections } from 'n8n-workflow';
import { jsonParse, TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow';
@ -18,9 +18,10 @@ export const TEMPLATES_DIR = join(CLI_DIR, 'templates');
export const NODES_BASE_DIR = dirname(require.resolve('n8n-nodes-base'));
export const EDITOR_UI_DIST_DIR = join(dirname(require.resolve('n8n-editor-ui')), 'dist');
export function getN8nPackageJson() {
return jsonParse<n8n.PackageJson>(readFileSync(join(CLI_DIR, 'package.json'), 'utf8'));
}
const packageJsonPath = join(CLI_DIR, 'package.json');
const n8nPackageJson = jsonParse<n8n.PackageJson>(readFileSync(packageJsonPath, 'utf8'));
export const N8N_VERSION = n8nPackageJson.version;
export const N8N_RELEASE_DATE = statSync(packageJsonPath).mtime;
export const STARTING_NODES = [
'@n8n/n8n-nodes-langchain.manualChatTrigger',
@ -28,8 +29,6 @@ export const STARTING_NODES = [
'n8n-nodes-base.manualTrigger',
];
export const N8N_VERSION = getN8nPackageJson().version;
export const NODE_PACKAGE_PREFIX = 'n8n-nodes-';
export const STARTER_TEMPLATE_NAME = `${NODE_PACKAGE_PREFIX}starter`;

View file

@ -5,7 +5,7 @@ import { InstanceSettings, Logger } from 'n8n-core';
import type { IWorkflowBase } from 'n8n-workflow';
import config from '@/config';
import { getN8nPackageJson, inDevelopment } from '@/constants';
import { inDevelopment, N8N_VERSION } from '@/constants';
import { isApiEnabled } from '@/public-api';
import {
ENV_VARS_DOCS_URL,
@ -175,7 +175,7 @@ export class InstanceRiskReporter implements RiskReporter {
private async getOutdatedState() {
let versions = [];
const localVersion = getN8nPackageJson().version;
const localVersion = N8N_VERSION;
try {
versions = await this.getNextVersions(localVersion).then((v) => this.removeIconData(v));

View file

@ -114,9 +114,8 @@ export const MOCK_PACKAGE: InstalledPackages[] = [
export function simulateOutdatedInstanceOnce(versionName = MOCK_01110_N8N_VERSION.name) {
const baseUrl = Container.get(GlobalConfig).versionNotifications.endpoint + '/';
jest
.spyOn(constants, 'getN8nPackageJson')
.mockReturnValueOnce({ name: 'n8n', version: versionName });
// @ts-expect-error readonly export
constants.N8N_VERSION = versionName;
nock(baseUrl).get(versionName).reply(200, [MOCK_01110_N8N_VERSION, MOCK_09990_N8N_VERSION]);
}
@ -124,9 +123,8 @@ export function simulateOutdatedInstanceOnce(versionName = MOCK_01110_N8N_VERSIO
export function simulateUpToDateInstance(versionName = MOCK_09990_N8N_VERSION.name) {
const baseUrl = Container.get(GlobalConfig).versionNotifications.endpoint + '/';
jest
.spyOn(constants, 'getN8nPackageJson')
.mockReturnValueOnce({ name: 'n8n', version: versionName });
// @ts-expect-error readonly export
constants.N8N_VERSION = versionName;
nock(baseUrl).persist().get(versionName).reply(200, [MOCK_09990_N8N_VERSION]);
}

View file

@ -24,7 +24,8 @@ type ErrorReporterInitOptions = {
beforeSendFilter?: (event: ErrorEvent, hint: EventHint) => boolean;
};
const SIX_WEEKS_IN_MS = 6 * 7 * 24 * 60 * 60 * 1000;
const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
const SIX_WEEKS_IN_MS = 6 * 7 * ONE_DAY_IN_MS;
const RELEASE_EXPIRATION_WARNING =
'Error tracking disabled because this release is older than 6 weeks.';
@ -86,17 +87,23 @@ export class ErrorReporter {
});
if (releaseDate) {
const releaseExpiresInMs = releaseDate.getTime() + SIX_WEEKS_IN_MS - Date.now();
if (releaseExpiresInMs <= 0) {
const releaseExpiresAtMs = releaseDate.getTime() + SIX_WEEKS_IN_MS;
const releaseExpiresInMs = () => releaseExpiresAtMs - Date.now();
if (releaseExpiresInMs() <= 0) {
this.logger.warn(RELEASE_EXPIRATION_WARNING);
return;
}
// Once this release expires, reject all events
this.expirationTimer = setTimeout(() => {
this.logger.warn(RELEASE_EXPIRATION_WARNING);
// eslint-disable-next-line @typescript-eslint/unbound-method
this.report = this.defaultReport;
}, releaseExpiresInMs);
const checkForExpiration = () => {
// Once this release expires, reject all events
if (releaseExpiresInMs() <= 0) {
this.logger.warn(RELEASE_EXPIRATION_WARNING);
// eslint-disable-next-line @typescript-eslint/unbound-method
this.report = this.defaultReport;
} else {
setTimeout(checkForExpiration, ONE_DAY_IN_MS);
}
};
checkForExpiration();
}
if (!dsn) return;

View file

@ -0,0 +1,217 @@
import { mock } from 'jest-mock-extended';
import type { IConnections, INode, INodeType, INodeTypes, IPinData } from 'n8n-workflow';
import { Workflow } from 'n8n-workflow';
import { toIConnections } from './helpers';
import { findTriggerForPartialExecution } from '../find-trigger-for-partial-execution';
describe('findTriggerForPartialExecution', () => {
const nodeTypes = mock<INodeTypes>();
const createMockWorkflow = (nodes: INode[], connections: IConnections, pinData?: IPinData) =>
new Workflow({
active: false,
nodes,
connections,
nodeTypes,
pinData,
});
const createNode = (name: string, type: string, disabled = false) =>
mock<INode>({ name, type, disabled });
const manualTriggerNode = createNode('ManualTrigger', 'n8n-nodes-base.manualTrigger');
const disabledTriggerNode = createNode('DisabledTrigger', 'n8n-nodes-base.manualTrigger', true);
const pinnedTrigger = createNode('PinnedTrigger', 'n8n-nodes-base.manualTrigger');
const setNode = createNode('Set', 'n8n-nodes-base.set');
const noOpNode = createNode('No Operation', 'n8n-nodes-base.noOp');
const webhookNode = createNode('Webhook', 'n8n-nodes-base.webhook');
const webhookNode1 = createNode('Webhook1', 'n8n-nodes-base.webhook');
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockImplementation((type) => {
const isTrigger = type.endsWith('Trigger') || type.endsWith('webhook');
return mock<INodeType>({
description: {
group: isTrigger ? ['trigger'] : [],
properties: [],
},
});
});
});
const testGroups: Record<
string,
Array<{
description: string;
nodes: INode[];
connections: Array<{ to: INode; from: INode }>;
destinationNodeName: string;
pinData?: IPinData;
expectedTrigger?: INode;
}>
> = {
'Single trigger node': [
{
description: 'should return the destination node if it is a trigger',
nodes: [manualTriggerNode],
connections: [],
destinationNodeName: manualTriggerNode.name,
expectedTrigger: manualTriggerNode,
},
{
description: 'should return a parent trigger node for a non-trigger destination',
nodes: [manualTriggerNode, setNode],
connections: [{ from: manualTriggerNode, to: setNode }],
destinationNodeName: setNode.name,
expectedTrigger: manualTriggerNode,
},
],
'Multiple trigger nodes': [
{
description: 'should prioritize webhook nodes when multiple parent triggers exist',
nodes: [webhookNode, manualTriggerNode, setNode],
connections: [
{ from: webhookNode, to: setNode },
{ from: manualTriggerNode, to: setNode },
],
destinationNodeName: setNode.name,
expectedTrigger: webhookNode,
},
{
description: 'should handle multiple webhook triggers',
nodes: [webhookNode, webhookNode1, setNode],
connections: [
{ from: webhookNode, to: setNode },
{ from: webhookNode1, to: setNode },
],
destinationNodeName: setNode.name,
expectedTrigger: webhookNode1,
},
{
description: 'should prioritize webhook node, even if it is further up',
nodes: [manualTriggerNode, setNode, noOpNode, webhookNode],
connections: [
{ from: manualTriggerNode, to: setNode },
{ from: setNode, to: noOpNode },
{ from: webhookNode, to: noOpNode },
],
destinationNodeName: noOpNode.name,
expectedTrigger: webhookNode,
},
{
description: 'should ignore disabled parent trigger nodes',
nodes: [disabledTriggerNode, manualTriggerNode, setNode],
connections: [
{ from: disabledTriggerNode, to: setNode },
{ from: manualTriggerNode, to: setNode },
],
destinationNodeName: setNode.name,
expectedTrigger: manualTriggerNode,
},
],
'No trigger nodes': [
{
description: 'should return undefined when no valid parent triggers found',
nodes: [setNode, noOpNode],
connections: [{ from: setNode, to: noOpNode }],
destinationNodeName: noOpNode.name,
expectedTrigger: undefined,
},
],
'Trigger node with pinned data': [
{
description: 'should prioritize pinned trigger nodes',
nodes: [pinnedTrigger, manualTriggerNode, setNode],
connections: [
{ from: pinnedTrigger, to: setNode },
{ from: manualTriggerNode, to: setNode },
],
destinationNodeName: setNode.name,
pinData: { [pinnedTrigger.name]: [{ json: { test: true } }] },
expectedTrigger: pinnedTrigger,
},
{
description: 'should prioritize pinned webhook triggers',
nodes: [pinnedTrigger, manualTriggerNode, webhookNode, setNode],
connections: [
{ from: pinnedTrigger, to: setNode },
{ from: webhookNode, to: setNode },
{ from: manualTriggerNode, to: setNode },
],
destinationNodeName: setNode.name,
pinData: {
[pinnedTrigger.name]: [{ json: { test: true } }],
[webhookNode.name]: [{ json: { test: true } }],
},
expectedTrigger: webhookNode,
},
{
description: 'should prioritize the first connected pinned webhook triggers',
nodes: [webhookNode, webhookNode1, pinnedTrigger, manualTriggerNode, setNode],
connections: [
{ from: pinnedTrigger, to: setNode },
{ from: webhookNode, to: setNode },
{ from: webhookNode1, to: setNode },
{ from: manualTriggerNode, to: setNode },
],
destinationNodeName: setNode.name,
pinData: {
[pinnedTrigger.name]: [{ json: { test: true } }],
[webhookNode.name]: [{ json: { test: true } }],
[webhookNode1.name]: [{ json: { test: true } }],
},
expectedTrigger: webhookNode,
},
{
description: 'should prioritize the first connected pinned webhook triggers (reverse)',
nodes: [webhookNode1, webhookNode, pinnedTrigger, manualTriggerNode, setNode],
connections: [
{ from: pinnedTrigger, to: setNode },
{ from: webhookNode1, to: setNode },
{ from: webhookNode, to: setNode },
{ from: manualTriggerNode, to: setNode },
],
destinationNodeName: setNode.name,
pinData: {
[pinnedTrigger.name]: [{ json: { test: true } }],
[webhookNode.name]: [{ json: { test: true } }],
[webhookNode1.name]: [{ json: { test: true } }],
},
expectedTrigger: webhookNode1,
},
],
};
for (const [group, tests] of Object.entries(testGroups)) {
describe(group, () => {
test.each(tests)(
'$description',
({ nodes, connections, destinationNodeName, expectedTrigger, pinData }) => {
const workflow = createMockWorkflow(nodes, toIConnections(connections), pinData);
expect(findTriggerForPartialExecution(workflow, destinationNodeName)).toBe(
expectedTrigger,
);
},
);
});
}
describe('Error and Edge Case Handling', () => {
it('should handle non-existent destination node gracefully', () => {
const workflow = createMockWorkflow([], {});
expect(findTriggerForPartialExecution(workflow, 'NonExistentNode')).toBeUndefined();
});
it('should handle empty workflow', () => {
const workflow = createMockWorkflow([], {});
expect(findTriggerForPartialExecution(workflow, '')).toBeUndefined();
});
it('should handle workflow with no connections', () => {
const workflow = createMockWorkflow([manualTriggerNode], {});
expect(findTriggerForPartialExecution(workflow, manualTriggerNode.name)).toBe(
manualTriggerNode,
);
});
});
});

View file

@ -1,5 +1,7 @@
import * as assert from 'assert/strict';
import type { INode, Workflow } from 'n8n-workflow';
import type { INode, INodeType, Workflow } from 'n8n-workflow';
const isTriggerNode = (nodeType: INodeType) => nodeType.description.group.includes('trigger');
function findAllParentTriggers(workflow: Workflow, destinationNodeName: string) {
const parentNodes = workflow
@ -17,35 +19,50 @@ function findAllParentTriggers(workflow: Workflow, destinationNodeName: string)
};
})
.filter((value) => value !== null)
.filter(({ nodeType }) => nodeType.description.group.includes('trigger'))
.filter(({ nodeType }) => isTriggerNode(nodeType))
.map(({ node }) => node);
return parentNodes;
}
// TODO: write unit tests for this
// TODO: rewrite this using DirectedGraph instead of workflow.
export function findTriggerForPartialExecution(
workflow: Workflow,
destinationNodeName: string,
): INode | undefined {
// First, check if the destination node itself is a trigger
const destinationNode = workflow.getNode(destinationNodeName);
if (!destinationNode) return;
const destinationNodeType = workflow.nodeTypes.getByNameAndVersion(
destinationNode.type,
destinationNode.typeVersion,
);
if (isTriggerNode(destinationNodeType) && !destinationNode.disabled) {
return destinationNode;
}
// Since the destination node wasn't a trigger, we try to find a parent node that's a trigger
const parentTriggers = findAllParentTriggers(workflow, destinationNodeName).filter(
(trigger) => !trigger.disabled,
);
// Prioritize webhook triggers with pinned-data
const pinnedTriggers = parentTriggers
// TODO: add the other filters here from `findAllPinnedActivators`, see
// copy below.
.filter((trigger) => workflow.pinData?.[trigger.name])
// TODO: Make this sorting more predictable
// Put nodes which names end with 'webhook' first, while also reversing the
// order they had in the original array.
.sort((n) => (n.type.endsWith('webhook') ? -1 : 1));
.sort((a, b) => (a.type.endsWith('webhook') ? -1 : b.type.endsWith('webhook') ? 1 : 0));
if (pinnedTriggers.length) {
return pinnedTriggers[0];
} else {
return parentTriggers[0];
}
// Prioritize webhook triggers over other parent triggers
const webhookTriggers = parentTriggers.filter((trigger) => trigger.type.endsWith('webhook'));
return webhookTriggers.length > 0 ? webhookTriggers[0] : parentTriggers[0];
}
//function findAllPinnedActivators(workflow: Workflow, pinData?: IPinData) {

View file

@ -94,11 +94,11 @@
"xss": "catalog:"
},
"devDependencies": {
"@faker-js/faker": "^8.0.2",
"@iconify/json": "^2.2.228",
"@n8n/eslint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@faker-js/faker": "^8.0.2",
"@iconify/json": "^2.2.228",
"@pinia/testing": "^0.1.6",
"@types/dateformat": "^3.0.0",
"@types/file-saver": "^2.0.1",
@ -111,6 +111,7 @@
"@vitejs/plugin-vue": "catalog:frontend",
"@vitest/coverage-v8": "catalog:frontend",
"browserslist-to-esbuild": "^2.1.1",
"fake-indexeddb": "^6.0.0",
"miragejs": "^0.1.48",
"unplugin-icons": "^0.19.0",
"unplugin-vue-components": "^0.27.2",

View file

@ -1,4 +1,5 @@
import '@testing-library/jest-dom';
import 'fake-indexeddb/auto';
import { configure } from '@testing-library/vue';
import 'core-js/proposals/set-methods-v2';
@ -64,20 +65,21 @@ Object.defineProperty(window, 'matchMedia', {
});
class Worker {
onmessage: (message: string) => void;
onmessage = vi.fn();
url: string;
constructor(url: string) {
this.url = url;
this.onmessage = () => {};
}
postMessage(message: string) {
postMessage = vi.fn((message: string) => {
this.onmessage(message);
}
});
addEventListener() {}
addEventListener = vi.fn();
terminate = vi.fn();
}
Object.defineProperty(window, 'Worker', {

View file

@ -198,6 +198,16 @@ const onSelect = (value: number) => {
expirationDate.value = '';
showExpirationDateSelector.value = false;
};
async function handleEnterKey(event: KeyboardEvent) {
if (event.key === 'Enter') {
if (props.mode === 'new') {
await onSave();
} else {
await onEdit();
}
}
}
</script>
<template>
@ -212,7 +222,7 @@ const onSelect = (value: number) => {
:show-close="true"
>
<template #content>
<div>
<div @keyup.enter="handleEnterKey">
<n8n-card v-if="newApiKey" class="mb-4xs">
<CopyInput
:label="newApiKey.label"

View file

@ -654,7 +654,6 @@ async function onAskAssistantClick() {
<style lang="scss">
.node-error-view {
&__header {
max-width: 960px;
margin: 0 auto var(--spacing-s) auto;
padding-bottom: var(--spacing-3xs);
background-color: var(--color-background-xlight);
@ -755,7 +754,6 @@ async function onAskAssistantClick() {
}
&__info {
max-width: 960px;
margin: 0 auto;
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-large);

View file

@ -300,6 +300,12 @@ async function onConnectionStateChange() {
flex-direction: row;
align-items: center;
flex: 1;
svg,
img {
max-width: 28px;
max-height: 28px;
}
}
.providerActions {

View file

@ -18,11 +18,12 @@ import type {
INodeParameterResourceLocator,
INodeParameters,
INodeProperties,
INodePropertyCollection,
INodePropertyOptions,
IParameterLabel,
NodeParameterValueType,
} from 'n8n-workflow';
import { CREDENTIAL_EMPTY_VALUE, NodeHelpers } from 'n8n-workflow';
import { CREDENTIAL_EMPTY_VALUE, isINodePropertyOptions, NodeHelpers } from 'n8n-workflow';
import CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.vue';
import CredentialsSelect from '@/components/CredentialsSelect.vue';
@ -66,7 +67,9 @@ import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import { useRouter } from 'vue-router';
import { useElementSize } from '@vueuse/core';
import { captureMessage } from '@sentry/vue';
import { completeExpressionSyntax, isStringWithExpressionSyntax } from '@/utils/expressions';
import { isPresent } from '@/utils/typesUtils';
import CssEditor from './CssEditor/CssEditor.vue';
type Picker = { $emit: (arg0: string, arg1: Date) => void };
@ -422,14 +425,11 @@ const editorLanguage = computed<CodeNodeEditorLanguage>(() => {
return getArgument<CodeNodeEditorLanguage>('editorLanguage') ?? 'javaScript';
});
const parameterOptions = computed<INodePropertyOptions[] | undefined>(() => {
if (!hasRemoteMethod.value) {
// Options are already given
return props.parameter.options as INodePropertyOptions[];
}
const parameterOptions = computed(() => {
const options = hasRemoteMethod.value ? remoteParameterOptions.value : props.parameter.options;
const safeOptions = (options ?? []).filter(isValidParameterOption);
// Options get loaded from server
return remoteParameterOptions.value;
return safeOptions;
});
const isSwitch = computed(
@ -571,6 +571,12 @@ const shouldCaptureForPosthog = computed(() => {
return false;
});
function isValidParameterOption(
option: INodePropertyOptions | INodeProperties | INodePropertyCollection,
): option is INodePropertyOptions {
return isINodePropertyOptions(option) && isPresent(option.value) && isPresent(option.name);
}
function isRemoteParameterOption(option: INodePropertyOptions) {
return remoteParameterOptionsKeys.value.includes(option.name);
}
@ -1084,6 +1090,28 @@ watch(isModelValueExpression, async (isExpression, wasExpression) => {
}
});
// Investigate invalid parameter options
// Sentry issue: https://n8nio.sentry.io/issues/6275981089/?project=4503960699273216
const unwatchParameterOptions = watch(
[remoteParameterOptions, () => props.parameter.options],
([remoteOptions, options]) => {
const allOptions = [...remoteOptions, ...(options ?? [])];
const invalidOptions = allOptions.filter((option) => !isValidParameterOption(option));
if (invalidOptions.length > 0) {
captureMessage('Invalid parameter options', {
level: 'error',
extra: {
invalidOptions,
parameter: props.parameter.name,
node: node.value,
},
});
unwatchParameterOptions();
}
},
);
onUpdated(async () => {
await nextTick();

View file

@ -449,7 +449,7 @@ defineExpose({
padding: 0 0 0 var(--spacing-s);
.parameterInput {
width: 100%;
width: calc(100% - var(--delete-option-width));
}
.parameterInput:first-child {

View file

@ -1827,6 +1827,7 @@ defineExpose({ enterEditMode });
:mapping-enabled="mappingEnabled"
:distance-from-active="distanceFromActive"
:run-index="runIndex"
:output-index="currentOutputIndex"
:total-runs="maxRunIndex"
:search="search"
/>

View file

@ -29,6 +29,7 @@ const props = withDefaults(
inputData: INodeExecutionData[];
mappingEnabled?: boolean;
distanceFromActive: number;
outputIndex: number | undefined;
runIndex: number | undefined;
totalRuns: number | undefined;
search: string | undefined;
@ -45,7 +46,6 @@ const telemetry = useTelemetry();
const selectedJsonPath = ref(nonExistingJsonPath);
const draggingPath = ref<null | string>(null);
const displayMode = ref('json');
const jsonDataContainer = ref(null);
const { height } = useElementSize(jsonDataContainer);
@ -119,12 +119,13 @@ const getListItemName = (path: string) => {
<LazyRunDataJsonActions
v-if="!editMode.enabled"
:node="node"
:pane-type="paneType"
:push-ref="pushRef"
:display-mode="displayMode"
:distance-from-active="distanceFromActive"
:selected-json-path="selectedJsonPath"
:json-data="jsonData"
:pane-type="paneType"
:output-index="outputIndex"
:run-index="runIndex"
/>
</Suspense>
<Draggable

View file

@ -0,0 +1,379 @@
import { reactive } from 'vue';
import { mock } from 'vitest-mock-extended';
import { createPinia, setActivePinia } from 'pinia';
import { waitFor, cleanup, fireEvent, within, screen } from '@testing-library/vue';
import RunDataJsonActions from './RunDataJsonActions.vue';
import { nonExistingJsonPath, VIEWS } from '@/constants';
import type { IWorkflowDb } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createComponentRenderer } from '@/__tests__/render';
import { setupServer } from '@/__tests__/server';
import { defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks';
import { useI18n } from '@/composables/useI18n';
vi.mock('vue-router', () => {
return {
useRouter: () => ({}),
useRoute: () => reactive({ meta: {} }),
RouterLink: vi.fn(),
};
});
const copy = vi.fn();
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copy,
}),
}));
const i18n = useI18n();
async function createPiniaWithActiveNode() {
const node = mockNodes[0];
const workflow = mock<IWorkflowDb>({
id: '1',
name: 'Test Workflow',
versionId: '1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
active: true,
connections: {},
nodes: [node],
});
const pinia = createPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
workflowsStore.workflow = workflow;
workflowsStore.nodeMetadata[node.name] = { pristine: true };
workflowsStore.workflowExecutionData = {
id: '1',
finished: true,
mode: 'trigger',
status: 'success',
createdAt: new Date(),
startedAt: new Date(),
workflowData: workflow,
data: {
resultData: {
runData: {
[node.name]: [
{
startTime: new Date().getTime(),
executionTime: new Date().getTime(),
data: {
main: [
[
{
json: {
id: 1,
name: 'First run 1',
},
},
{
json: {
id: 2,
name: 'First run 2',
},
},
],
],
},
source: [null],
},
{
startTime: new Date().getTime(),
executionTime: new Date().getTime(),
data: {
main: [
[
{
json: {
id: 3,
name: 'Second run 1',
},
},
],
],
},
source: [null],
},
],
},
},
},
};
ndvStore.activeNodeName = node.name;
return {
pinia,
activeNode: ndvStore.activeNode,
};
}
describe('RunDataJsonActions', () => {
let server: ReturnType<typeof setupServer>;
beforeEach(cleanup);
beforeAll(() => {
document.body.innerHTML = '<div id="app-grid"></div>';
server = setupServer();
});
afterEach(() => {
copy.mockReset();
vi.clearAllMocks();
});
afterAll(() => {
server.shutdown();
});
it('should copy unselected JSON output from latest run on click', async () => {
const { pinia, activeNode } = await createPiniaWithActiveNode();
const renderComponent = createComponentRenderer(RunDataJsonActions, {
props: {
node: activeNode,
paneType: 'output',
pushRef: 'ref',
displayMode: 'json',
distanceFromActive: 0,
selectedJsonPath: nonExistingJsonPath,
jsonData: [
{
id: 3,
name: 'Second run 1',
},
],
outputIndex: 0,
runIndex: 1,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
});
const { getByTestId } = renderComponent({
pinia,
});
await waitFor(() => expect(getByTestId('ndv-json-actions')).toBeInTheDocument());
const button = within(getByTestId('ndv-json-actions')).getByRole('button');
await fireEvent.click(button);
expect(copy).toHaveBeenCalledWith(
JSON.stringify(
[
{
id: 3,
name: 'Second run 1',
},
],
null,
2,
),
);
});
it('should copy unselected JSON output from selected previous run on click', async () => {
const { pinia, activeNode } = await createPiniaWithActiveNode();
const renderComponent = createComponentRenderer(RunDataJsonActions, {
props: {
node: activeNode,
paneType: 'output',
pushRef: 'ref',
displayMode: 'json',
distanceFromActive: 0,
selectedJsonPath: nonExistingJsonPath,
jsonData: [
{
id: 3,
name: 'Second run 1',
},
],
outputIndex: 0,
runIndex: 0,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
});
const { getByTestId } = renderComponent({
pinia,
});
await waitFor(() => expect(getByTestId('ndv-json-actions')).toBeInTheDocument());
const button = within(getByTestId('ndv-json-actions')).getByRole('button');
await fireEvent.click(button);
expect(copy).toHaveBeenCalledWith(
JSON.stringify(
[
{
id: 1,
name: 'First run 1',
},
{
id: 2,
name: 'First run 2',
},
],
null,
2,
),
);
});
it("should copy selected JSON value on 'Copy Selection' click", async () => {
const { pinia, activeNode } = await createPiniaWithActiveNode();
const renderComponent = createComponentRenderer(RunDataJsonActions, {
props: {
node: activeNode,
paneType: 'output',
pushRef: 'ref',
displayMode: 'json',
distanceFromActive: 0,
selectedJsonPath: '[0].name',
jsonData: [
{
id: 3,
name: 'Second run 1',
},
],
outputIndex: 0,
runIndex: 1,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
});
renderComponent({
pinia,
});
await waitFor(() =>
expect(screen.getByText(i18n.baseText('runData.copyValue'))).toBeInTheDocument(),
);
const option = screen.getByText(i18n.baseText('runData.copyValue'));
await fireEvent.click(option);
expect(copy).toHaveBeenCalledWith('Second run 1');
});
it("should copy selected JSON value's item path on 'Copy Item Path' click", async () => {
const { pinia, activeNode } = await createPiniaWithActiveNode();
const renderComponent = createComponentRenderer(RunDataJsonActions, {
props: {
node: activeNode,
paneType: 'output',
pushRef: 'ref',
displayMode: 'json',
distanceFromActive: 0,
selectedJsonPath: '[0].name',
jsonData: [
{
id: 3,
name: 'Second run 1',
},
],
outputIndex: 0,
runIndex: 1,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
});
renderComponent({
pinia,
});
await waitFor(() =>
expect(screen.getByText(i18n.baseText('runData.copyItemPath'))).toBeInTheDocument(),
);
const option = screen.getByText(i18n.baseText('runData.copyItemPath'));
await fireEvent.click(option);
expect(copy).toHaveBeenCalledWith('{{ $item("0").$node["Manual Trigger"].json["name"] }}');
});
it("should copy selected JSON value's parameter path on 'Copy Parameter Path' click", async () => {
const { pinia, activeNode } = await createPiniaWithActiveNode();
const renderComponent = createComponentRenderer(RunDataJsonActions, {
props: {
node: activeNode,
paneType: 'output',
pushRef: 'ref',
displayMode: 'json',
distanceFromActive: 0,
selectedJsonPath: '[0].name',
jsonData: [
{
id: 3,
name: 'Second run 1',
},
],
outputIndex: 0,
runIndex: 1,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
});
renderComponent({
pinia,
});
await waitFor(() =>
expect(screen.getByText(i18n.baseText('runData.copyParameterPath'))).toBeInTheDocument(),
);
const option = screen.getByText(i18n.baseText('runData.copyParameterPath'));
await fireEvent.click(option);
expect(copy).toHaveBeenCalledWith('{{ $node["Manual Trigger"].json["name"] }}');
});
});

View file

@ -26,12 +26,11 @@ const props = withDefaults(
node: INodeUi;
paneType: string;
pushRef: string;
displayMode: string;
distanceFromActive: number;
selectedJsonPath: string;
jsonData: IDataObject[];
currentOutputIndex?: number;
runIndex?: number;
outputIndex: number | undefined;
runIndex: number | undefined;
}>(),
{
selectedJsonPath: nonExistingJsonPath,
@ -71,7 +70,7 @@ function getJsonValue(): string {
selectedValue = clearJsonKey(pinnedData.data.value as object);
} else {
selectedValue = executionDataToJson(
nodeHelpers.getNodeInputData(props.node, props.runIndex, props.currentOutputIndex),
nodeHelpers.getNodeInputData(props.node, props.runIndex, props.outputIndex),
);
}
}
@ -176,7 +175,7 @@ function handleCopyClick(commandData: { command: string }) {
</script>
<template>
<div :class="$style.actionsGroup">
<div :class="$style.actionsGroup" data-test-id="ndv-json-actions">
<n8n-icon-button
v-if="noSelection"
:title="i18n.baseText('runData.copyToClipboard')"

View file

@ -314,7 +314,7 @@ const onDragEnd = (el: HTMLElement) => {
@click="toggleNodeAndScrollTop(item.id)"
/>
<VirtualSchemaItem
v-else
v-else-if="item.type === 'item'"
v-bind="item"
:search="search"
:draggable="mappingEnabled"
@ -323,6 +323,10 @@ const onDragEnd = (el: HTMLElement) => {
@click="toggleLeaf(item.id)"
>
</VirtualSchemaItem>
<N8nTooltip v-else-if="item.type === 'icon'" :content="item.tooltip" placement="top">
<N8nIcon :size="14" :icon="item.icon" class="icon" />
</N8nTooltip>
</DynamicScrollerItem>
</template>
</DynamicScroller>
@ -347,4 +351,11 @@ const onDragEnd = (el: HTMLElement) => {
text-align: center;
padding: var(--spacing-s) var(--spacing-s) var(--spacing-xl) var(--spacing-s);
}
.icon {
display: inline-flex;
margin-left: var(--spacing-xl);
color: var(--color-text-light);
margin-bottom: var(--spacing-s);
}
</style>

View file

@ -4,8 +4,7 @@ import NodeIcon from '@/components/NodeIcon.vue';
import { type INodeTypeDescription } from 'n8n-workflow';
import { useI18n } from '@/composables/useI18n';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { DATA_EDITING_DOCS_URL } from '@/constants';
import { N8nNotice } from '@n8n/design-system';
import { SCHEMA_PREVIEW_DOCS_URL } from '@/constants';
const props = defineProps<{
title: string;
@ -42,24 +41,28 @@ const emit = defineEmits<{
<span v-if="info" class="info">{{ info }}</span>
</div>
<FontAwesomeIcon v-if="isTrigger" class="trigger-icon" icon="bolt" size="xs" />
<div v-if="itemCount" class="item-count" data-test-id="run-data-schema-node-item-count">
<div v-if="itemCount" class="extra-info" data-test-id="run-data-schema-node-item-count">
{{ i18n.baseText('ndv.output.items', { interpolate: { count: itemCount } }) }}
</div>
<div v-else-if="preview" class="extra-info">
{{ i18n.baseText('dataMapping.schemaView.previewNode') }}
</div>
</div>
<N8nNotice
<div
v-if="preview && !collapsed"
class="notice"
theme="warning"
data-test-id="schema-preview-warning"
@click.stop
>
<i18n-t keypath="dataMapping.schemaView.preview">
<template #link>
<N8nLink :to="DATA_EDITING_DOCS_URL" size="small">
<N8nLink :to="SCHEMA_PREVIEW_DOCS_URL" size="small" bold>
{{ i18n.baseText('generic.learnMore') }}
</N8nLink>
</template>
</i18n-t>
</N8nNotice>
</div>
</div>
</template>
@ -117,7 +120,7 @@ const emit = defineEmits<{
color: var(--color-primary);
}
.item-count {
.extra-info {
font-size: var(--font-size-2xs);
color: var(--color-text-light);
margin-left: auto;
@ -126,6 +129,9 @@ const emit = defineEmits<{
.notice {
margin-left: var(--spacing-2xl);
margin-top: var(--spacing-2xs);
margin-bottom: 0;
padding-bottom: var(--spacing-2xs);
color: var(--color-text-base);
font-size: var(--font-size-2xs);
line-height: var(--font-line-height-loose);
}
</style>

View file

@ -43,7 +43,10 @@ const emit = defineEmits<{
:data-node-type="nodeType"
data-target="mappable"
class="pill"
:class="{ 'pill--highlight': highlight, 'pill--preview': preview }"
:class="{
'pill--highlight': highlight,
'pill--preview': preview,
}"
data-test-id="run-data-schema-node-name"
>
<FontAwesomeIcon class="type-icon" :icon size="sm" />
@ -77,6 +80,7 @@ const emit = defineEmits<{
justify-content: center;
cursor: pointer;
font-size: var(--font-size-s);
color: var(--color-text-light);
}
.pill {
@ -98,16 +102,31 @@ const emit = defineEmits<{
}
&.pill--preview {
border-style: dashed;
border-width: 1.5px;
/* Cannot use CSS variable inside data URL, so instead switching based on data-theme and media query */
--schema-preview-dashed-border: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' viewBox='0 0 400 400' fill='none' rx='4' ry='4' stroke='%230000002A' stroke-width='2' stroke-dasharray='4%2c 4' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e");
--schema-preview-dashed-border-dark: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' viewBox='0 0 400 400' fill='none' rx='4' ry='4' stroke='%23FFFFFF2A' stroke-width='2' stroke-dasharray='4%2c 4' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e");
color: var(--color-text-light);
background-color: var(--color-run-data-background);
border: none;
max-width: calc(100% - var(--spacing-l));
background-image: var(--schema-preview-dashed-border);
.title {
color: var(--color-text-light);
border-left: 1.5px dashed var(--color-foreground-light);
}
}
}
@media (prefers-color-scheme: dark) {
body:not([data-theme]) .pill--preview {
background-image: var(--schema-preview-dashed-border-dark);
}
}
[data-theme='dark'] .pill--preview {
background-image: var(--schema-preview-dashed-border-dark);
}
.draggable .pill.pill--highlight {
color: var(--color-primary);
border-color: var(--color-primary-tint-1);

View file

@ -110,7 +110,12 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
swapopacity="false"
symbol="false"
/>
<!--v-if-->
<div
class="extra-info"
data-v-882a318e=""
>
Preview
</div>
</div>
<div
class="notice"
@ -119,13 +124,12 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
theme="warning"
>
This is a preview of the schema, execute the node to see the exact schema and data.
Usually outputs the following fields. Execute the node to see the actual ones.
<a
class="n8n-link"
data-v-882a318e=""
href="https://docs.n8n.io/data/data-editing/"
href="https://docs.n8n.io/data/schema-preview/"
target="_blank"
>
@ -133,7 +137,7 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
class="primary"
>
<span
class="n8n-text size-small regular"
class="n8n-text size-small bold"
>
@ -146,7 +150,6 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
</a>
</div>
</div>
@ -328,70 +331,38 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
<div
class="schema-item draggable"
data-test-id="run-data-schema-item"
data-v-0f5e7239=""
<span
class="n8n-text compact size-14 regular n8n-icon icon el-tooltip__trigger el-tooltip__trigger icon el-tooltip__trigger el-tooltip__trigger n8n-icon icon el-tooltip__trigger el-tooltip__trigger icon el-tooltip__trigger el-tooltip__trigger"
data-v-d00cba9a=""
type="item"
>
<div
class="toggle-container"
data-v-0f5e7239=""
>
<!--v-if-->
</div>
<div
class="pill pill--preview"
data-name="..."
data-nest-level="1"
data-target="mappable"
data-test-id="run-data-schema-node-name"
data-v-0f5e7239=""
>
<font-awesome-icon-stub
beat="false"
beatfade="false"
border="false"
bounce="false"
class="type-icon"
data-v-0f5e7239=""
fade="false"
fixedwidth="false"
flash="false"
flip="false"
icon=""
inverse="false"
listitem="false"
pulse="false"
shake="false"
size="sm"
spin="false"
spinpulse="false"
spinreverse="false"
swapopacity="false"
symbol="false"
/>
<span
class="content title"
data-v-0f5e7239=""
>
<span>
<!--v-if-->
...
</span>
</span>
</div>
<span
class="content text"
data-test-id="run-data-schema-item-value"
data-v-0f5e7239=""
>
<span />
</span>
</div>
<font-awesome-icon-stub
beat="false"
beatfade="false"
border="false"
bounce="false"
class="14"
fade="false"
fixedwidth="false"
flash="false"
flip="false"
icon="ellipsis-h"
inverse="false"
listitem="false"
pulse="false"
shake="false"
spin="false"
spinpulse="false"
spinreverse="false"
swapopacity="false"
symbol="false"
/>
</span>
<!--teleport start-->
<!--teleport end-->
@ -464,7 +435,12 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
<!--v-if-->
</div>
<!--v-if-->
<!--v-if-->
<div
class="extra-info"
data-v-882a318e=""
>
Preview
</div>
</div>
<div
class="notice"
@ -473,13 +449,12 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
theme="warning"
>
This is a preview of the schema, execute the node to see the exact schema and data.
Usually outputs the following fields. Execute the node to see the actual ones.
<a
class="n8n-link"
data-v-882a318e=""
href="https://docs.n8n.io/data/data-editing/"
href="https://docs.n8n.io/data/schema-preview/"
target="_blank"
>
@ -487,7 +462,7 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
class="primary"
>
<span
class="n8n-text size-small regular"
class="n8n-text size-small bold"
>
@ -500,7 +475,6 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
</a>
</div>
</div>
@ -682,70 +656,38 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
<div
class="schema-item draggable"
data-test-id="run-data-schema-item"
data-v-0f5e7239=""
<span
class="n8n-text compact size-14 regular n8n-icon icon el-tooltip__trigger el-tooltip__trigger icon el-tooltip__trigger el-tooltip__trigger n8n-icon icon el-tooltip__trigger el-tooltip__trigger icon el-tooltip__trigger el-tooltip__trigger"
data-v-d00cba9a=""
type="item"
>
<div
class="toggle-container"
data-v-0f5e7239=""
>
<!--v-if-->
</div>
<div
class="pill pill--preview"
data-name="..."
data-nest-level="1"
data-target="mappable"
data-test-id="run-data-schema-node-name"
data-v-0f5e7239=""
>
<font-awesome-icon-stub
beat="false"
beatfade="false"
border="false"
bounce="false"
class="type-icon"
data-v-0f5e7239=""
fade="false"
fixedwidth="false"
flash="false"
flip="false"
icon=""
inverse="false"
listitem="false"
pulse="false"
shake="false"
size="sm"
spin="false"
spinpulse="false"
spinreverse="false"
swapopacity="false"
symbol="false"
/>
<span
class="content title"
data-v-0f5e7239=""
>
<span>
<!--v-if-->
...
</span>
</span>
</div>
<span
class="content text"
data-test-id="run-data-schema-item-value"
data-v-0f5e7239=""
>
<span />
</span>
</div>
<font-awesome-icon-stub
beat="false"
beatfade="false"
border="false"
bounce="false"
class="14"
fade="false"
fixedwidth="false"
flash="false"
flip="false"
icon="ellipsis-h"
inverse="false"
listitem="false"
pulse="false"
shake="false"
spin="false"
spinpulse="false"
spinreverse="false"
swapopacity="false"
symbol="false"
/>
</span>
<!--teleport start-->
<!--teleport end-->
@ -821,7 +763,7 @@ exports[`VirtualSchema.vue > renders previous nodes schema for AI tools 1`] = `
</div>
<!--v-if-->
<div
class="item-count"
class="extra-info"
data-test-id="run-data-schema-node-item-count"
data-v-882a318e=""
>
@ -893,7 +835,7 @@ exports[`VirtualSchema.vue > renders schema for correct output branch 1`] = `
</div>
<!--v-if-->
<div
class="item-count"
class="extra-info"
data-test-id="run-data-schema-node-item-count"
data-v-882a318e=""
>
@ -1447,7 +1389,7 @@ exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = `
symbol="false"
/>
<div
class="item-count"
class="extra-info"
data-test-id="run-data-schema-node-item-count"
data-v-882a318e=""
>

View file

@ -261,7 +261,14 @@ export type RenderHeader = {
preview?: boolean;
};
type Renders = RenderHeader | RenderItem;
export type RenderIcon = {
id: string;
type: 'icon';
icon: string;
tooltip: string;
};
type Renders = RenderHeader | RenderItem | RenderIcon;
const icons = {
object: 'cube',
@ -285,13 +292,11 @@ const emptyItem = (): RenderItem => ({
type: 'item',
});
const dummyItem = (): RenderItem => ({
id: `dummy-${window.crypto.randomUUID()}`,
icon: '',
level: 1,
title: '...',
type: 'item',
preview: true,
const moreFieldsItem = (): RenderIcon => ({
id: `moreFields-${window.crypto.randomUUID()}`,
type: 'icon',
icon: 'ellipsis-h',
tooltip: useI18n().baseText('dataMapping.schemaView.previewExtraFields'),
});
const isDataEmpty = (schema: Schema) => {
@ -445,7 +450,7 @@ export const useFlattenSchema = () => {
acc.push(...flattenSchema(item));
if (item.preview) {
acc.push(dummyItem());
acc.push(moreFieldsItem());
}
return acc;

View file

@ -92,6 +92,7 @@ export const BUILTIN_NODES_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/built
export const BUILTIN_CREDENTIALS_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/builtin/credentials/`;
export const DATA_PINNING_DOCS_URL = `https://${DOCS_DOMAIN}/data/data-pinning/`;
export const DATA_EDITING_DOCS_URL = `https://${DOCS_DOMAIN}/data/data-editing/`;
export const SCHEMA_PREVIEW_DOCS_URL = `https://${DOCS_DOMAIN}/data/schema-preview/`;
export const MFA_DOCS_URL = `https://${DOCS_DOMAIN}/user-management/two-factor-auth/`;
export const NPM_COMMUNITY_NODE_SEARCH_API_URL = 'https://api.npms.io/v2/';
export const NPM_PACKAGE_DOCS_BASE_URL = 'https://www.npmjs.com/package/';

View file

@ -14,7 +14,7 @@ import { Text, type Extension } from '@codemirror/state';
import { EditorView, hoverTooltip } from '@codemirror/view';
import * as Comlink from 'comlink';
import { NodeConnectionType, type CodeExecutionMode, type INodeExecutionData } from 'n8n-workflow';
import { ref, toRef, toValue, watch, type MaybeRefOrGetter } from 'vue';
import { onBeforeUnmount, ref, toRef, toValue, watch, type MaybeRefOrGetter } from 'vue';
import type { LanguageServiceWorker, RemoteLanguageServiceWorkerInit } from '../types';
import { typescriptCompletionSource } from './completions';
import { typescriptWorkerFacet } from './facet';
@ -33,11 +33,13 @@ export function useTypescript(
const { debounce } = useDebounce();
const activeNodeName = ndvStore.activeNodeName;
const worker = ref<Comlink.Remote<LanguageServiceWorker>>();
const webWorker = ref<Worker>();
async function createWorker(): Promise<Extension> {
const { init } = Comlink.wrap<RemoteLanguageServiceWorkerInit>(
new Worker(new URL('../worker/typescript.worker.ts', import.meta.url), { type: 'module' }),
);
webWorker.value = new Worker(new URL('../worker/typescript.worker.ts', import.meta.url), {
type: 'module',
});
const { init } = Comlink.wrap<RemoteLanguageServiceWorkerInit>(webWorker.value);
worker.value = await init(
{
id: toValue(id),
@ -125,6 +127,10 @@ export function useTypescript(
forceParse(editor);
});
onBeforeUnmount(() => {
if (webWorker.value) webWorker.value.terminate();
});
return {
createWorker,
};

View file

@ -0,0 +1,96 @@
import { mock, mockDeep } from 'vitest-mock-extended';
import type * as tsvfs from '@typescript/vfs';
import ts from 'typescript';
import { getDiagnostics, tsCategoryToSeverity } from './linter';
describe('linter > getDiagnostics', () => {
it('should return diagnostics for a given file', () => {
const env = mockDeep<tsvfs.VirtualTypeScriptEnvironment>();
const fileName = 'testFile.ts';
const semanticDiagnostics = [
mock<ts.DiagnosticWithLocation>({
file: { fileName },
start: 0,
length: 10,
messageText: 'Semantic error',
category: ts.DiagnosticCategory.Error,
code: 1234,
}),
];
const syntacticDiagnostics = [
mock<ts.DiagnosticWithLocation>({
file: { fileName },
start: 15,
length: 5,
messageText: 'Syntactic warning',
category: ts.DiagnosticCategory.Warning,
code: 5678,
}),
];
env.languageService.getSemanticDiagnostics.mockReturnValue(semanticDiagnostics);
env.languageService.getSyntacticDiagnostics.mockReturnValue(syntacticDiagnostics);
env.getSourceFile.mockReturnValue({ fileName } as ts.SourceFile);
const result = getDiagnostics({ env, fileName });
expect(result).toHaveLength(2);
expect(result[0].message).toBe('Semantic error');
expect(result[0].severity).toBe('error');
expect(result[1].message).toBe('Syntactic warning');
expect(result[1].severity).toBe('warning');
});
it('should filter out ignored diagnostics', () => {
const env = mockDeep<tsvfs.VirtualTypeScriptEnvironment>();
const fileName = 'testFile.ts';
const diagnostics = [
mock<ts.DiagnosticWithLocation>({
file: { fileName },
start: 0,
length: 10,
messageText: 'No implicit any',
category: ts.DiagnosticCategory.Error,
code: 7006,
}),
mock<ts.DiagnosticWithLocation>({
file: { fileName },
start: 0,
length: 10,
messageText: 'Cannot find module or its corresponding type declarations.',
category: ts.DiagnosticCategory.Error,
code: 2307,
}),
mock<ts.DiagnosticWithLocation>({
file: { fileName },
start: 0,
length: 10,
messageText: 'Not ignored error',
category: ts.DiagnosticCategory.Error,
code: 1234,
}),
];
env.languageService.getSemanticDiagnostics.mockReturnValue(diagnostics);
env.languageService.getSyntacticDiagnostics.mockReturnValue([]);
env.getSourceFile.mockReturnValue({ fileName } as ts.SourceFile);
const result = getDiagnostics({ env, fileName });
expect(result).toHaveLength(1);
expect(result[0].message).toBe('Not ignored error');
});
it('should map diagnostic categories to severities correctly', () => {
const diagnostic = mock<ts.DiagnosticWithLocation>({
category: ts.DiagnosticCategory.Error,
code: 1234,
});
const severity = tsCategoryToSeverity(diagnostic);
expect(severity).toBe('error');
});
});

View file

@ -49,8 +49,15 @@ function isDiagnosticWithLocation(
}
function isIgnoredDiagnostic(diagnostic: ts.Diagnostic) {
// No implicit any
return diagnostic.code === 7006;
switch (diagnostic.code) {
// No implicit any
case 7006:
// Cannot find module or its corresponding type declarations.
case 2307:
return true;
}
return false;
}
/**

View file

@ -0,0 +1,118 @@
import type { WorkerInitOptions } from '../types';
import { worker } from './typescript.worker';
import { type ChangeSet, EditorState } from '@codemirror/state';
async function createWorker({
doc,
options,
}: { doc?: string; options?: Partial<WorkerInitOptions> } = {}) {
const defaultDoc = `
function myFunction(){
if (true){
const myObj = {test: "value"}
}
}
return $input.all();`;
const state = EditorState.create({ doc: doc ?? defaultDoc });
const tsWorker = worker.init(
{
allNodeNames: [],
content: state.doc.toJSON(),
id: 'id',
inputNodeNames: [],
mode: 'runOnceForAllItems',
variables: [],
...options,
},
async () => ({
json: { path: '', type: 'string', value: '' },
binary: [],
params: { path: '', type: 'string', value: '' },
}),
);
return await tsWorker;
}
describe('Typescript Worker', () => {
it('should return diagnostics', async () => {
const tsWorker = await createWorker();
expect(tsWorker.getDiagnostics()).toEqual([
{
from: 10,
markClass: 'cm-faded',
message: "'myFunction' is declared but its value is never read.",
severity: 'warning',
to: 20,
},
{
from: 47,
markClass: 'cm-faded',
message: "'myObj' is declared but its value is never read.",
severity: 'warning',
to: 52,
},
]);
});
it('should accept updates from the client and buffer them', async () => {
const tsWorker = await createWorker();
// Add if statement and remove indentation
const changes = [
[75, [0, '', ''], 22],
[76, [0, '', ''], 22],
[77, [0, ' if (true){', ' const myObj = {test: "value"}', ' }'], 22],
[77, [1], 13, [2], 30, [2], 23],
];
vi.useFakeTimers({ toFake: ['setTimeout', 'queueMicrotask', 'nextTick'] });
for (const change of changes) {
tsWorker.updateFile(change as unknown as ChangeSet);
}
expect(tsWorker.getDiagnostics()).toHaveLength(2);
vi.advanceTimersByTime(1000);
vi.runAllTicks();
expect(tsWorker.getDiagnostics()).toHaveLength(3);
expect(tsWorker.getDiagnostics()).toEqual([
{
from: 10,
markClass: 'cm-faded',
message: "'myFunction' is declared but its value is never read.",
severity: 'warning',
to: 20,
},
{
from: 47,
markClass: 'cm-faded',
message: "'myObj' is declared but its value is never read.",
severity: 'warning',
to: 52,
},
{
from: 96,
markClass: 'cm-faded',
message: "'myObj' is declared but its value is never read.",
severity: 'warning',
to: 101,
},
]);
});
it('should return completions', async () => {
const doc = 'return $input.';
const tsWorker = await createWorker({ doc });
const completionResult = await tsWorker.getCompletionsAtPos(doc.length);
assert(completionResult !== null);
const completionLabels = completionResult.result.options.map((c) => c.label);
expect(completionLabels).toContain('all()');
expect(completionLabels).toContain('first()');
});
});

View file

@ -28,7 +28,7 @@ import { until } from '@vueuse/core';
self.process = { env: {} } as NodeJS.Process;
const worker: LanguageServiceWorkerInit = {
export const worker: LanguageServiceWorkerInit = {
async init(options, nodeDataFetcher) {
const loadedNodeTypesMap: Map<string, { type: string; typeName: string }> = reactive(new Map());
@ -157,11 +157,11 @@ const worker: LanguageServiceWorkerInit = {
});
const applyChangesToCode = bufferChangeSets((bufferedChanges) => {
bufferedChanges.iterChanges((start, end, _fromNew, _toNew, text) => {
bufferedChanges.iterChanges((start, end, fromNew, _toNew, text) => {
const length = end - start;
env.updateFile(codeFileName, text.toString(), {
start: editorPositionToTypescript(start),
start: editorPositionToTypescript(fromNew),
length,
});
});

View file

@ -661,8 +661,9 @@
"dataMapping.schemaView.emptyData": "No fields - item(s) exist, but they're empty",
"dataMapping.schemaView.disabled": "This node is disabled and will just pass data through",
"dataMapping.schemaView.noMatches": "No results for '{search}'",
"dataMapping.schemaView.preview": "This is a preview of the schema, execute the node to see the exact schema and data. {link}",
"dataMapping.schemaView.previewNode": "(schema preview)",
"dataMapping.schemaView.preview": "Usually outputs the following fields. Execute the node to see the actual ones. {link}",
"dataMapping.schemaView.previewExtraFields": "There may be more fields. Execute the node to be sure.",
"dataMapping.schemaView.previewNode": "Preview",
"displayWithChange.cancelEdit": "Cancel Edit",
"displayWithChange.clickToChange": "Click to Change",
"displayWithChange.setValue": "Set Value",

View file

@ -12,7 +12,13 @@ import type { IExecutionResponse, INodeUi, IWorkflowDb, IWorkflowSettings } from
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import type { IPinData, ExecutionSummary, IConnection, INodeExecutionData } from 'n8n-workflow';
import type {
IPinData,
ExecutionSummary,
IConnection,
INodeExecutionData,
INode,
} from 'n8n-workflow';
import { stringSizeInBytes } from '@/utils/typesUtils';
import { dataPinningEventBus } from '@/event-bus';
import { useUIStore } from '@/stores/ui.store';
@ -681,6 +687,83 @@ describe('useWorkflowsStore', () => {
});
});
describe('setNodes()', () => {
it('should transform credential-only nodes', () => {
const setNodeId = '1';
const credentialOnlyNodeId = '2';
workflowsStore.setNodes([
mock<INode>({
id: setNodeId,
name: 'Edit Fields',
type: 'n8n-nodes-base.set',
}),
mock<INode>({
id: credentialOnlyNodeId,
name: 'AlienVault Request',
type: 'n8n-nodes-base.httpRequest',
extendsCredential: 'alienVaultApi',
}),
]);
expect(workflowsStore.workflow.nodes[0].id).toEqual(setNodeId);
expect(workflowsStore.workflow.nodes[1].id).toEqual(credentialOnlyNodeId);
expect(workflowsStore.workflow.nodes[1].type).toEqual('n8n-creds-base.alienVaultApi');
expect(workflowsStore.nodeMetadata).toEqual({
'AlienVault Request': { pristine: true },
'Edit Fields': { pristine: true },
});
});
});
describe('updateNodeAtIndex', () => {
it.each([
{
description: 'should update node at given index with provided data',
nodeIndex: 0,
nodeData: { name: 'Updated Node' },
initialNodes: [{ name: 'Original Node' }],
expectedNodes: [{ name: 'Updated Node' }],
expectedResult: true,
},
{
description: 'should not update node if index is invalid',
nodeIndex: -1,
nodeData: { name: 'Updated Node' },
initialNodes: [{ name: 'Original Node' }],
expectedNodes: [{ name: 'Original Node' }],
expectedResult: false,
},
{
description: 'should return false if node data is unchanged',
nodeIndex: 0,
nodeData: { name: 'Original Node' },
initialNodes: [{ name: 'Original Node' }],
expectedNodes: [{ name: 'Original Node' }],
expectedResult: false,
},
{
description: 'should update multiple properties of a node',
nodeIndex: 0,
nodeData: { name: 'Updated Node', type: 'newType' },
initialNodes: [{ name: 'Original Node', type: 'oldType' }],
expectedNodes: [{ name: 'Updated Node', type: 'newType' }],
expectedResult: true,
},
])('$description', ({ nodeIndex, nodeData, initialNodes, expectedNodes, expectedResult }) => {
workflowsStore.workflow.nodes = initialNodes as unknown as IWorkflowDb['nodes'];
const result = workflowsStore.updateNodeAtIndex(nodeIndex, nodeData);
expect(result).toBe(expectedResult);
expect(workflowsStore.workflow.nodes).toEqual(expectedNodes);
});
it('should throw error if out of bounds', () => {
workflowsStore.workflow.nodes = [];
expect(() => workflowsStore.updateNodeAtIndex(0, { name: 'Updated Node' })).toThrowError();
});
});
test.each([
// check userVersion behavior
[-1, 1, 1], // userVersion -1, use default (1)

View file

@ -62,7 +62,7 @@ import {
SEND_AND_WAIT_OPERATION,
Workflow,
} from 'n8n-workflow';
import { findLast } from 'lodash-es';
import { findLast, pick, isEqual } from 'lodash-es';
import { useRootStore } from '@/stores/root.store';
import * as workflowsApi from '@/api/workflows';
@ -1117,6 +1117,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
nodeHelpers.assignNodeId(node);
}
if (node.extendsCredential) {
node.type = getCredentialOnlyNodeTypeName(node.extendsCredential);
}
if (!nodeMetadata.value[node.name]) {
nodeMetadata.value[node.name] = { pristine: true };
}
@ -1138,10 +1142,17 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return true;
}
function updateNodeAtIndex(nodeIndex: number, nodeData: Partial<INodeUi>): void {
/**
* @returns `true` if the object was changed
*/
function updateNodeAtIndex(nodeIndex: number, nodeData: Partial<INodeUi>): boolean {
if (nodeIndex !== -1) {
Object.assign(workflow.value.nodes[nodeIndex], nodeData);
const node = workflow.value.nodes[nodeIndex];
const changed = !isEqual(pick(node, Object.keys(nodeData)), nodeData);
Object.assign(node, nodeData);
return changed;
}
return false;
}
function setNodeIssue(nodeIssueData: INodeIssueData): boolean {
@ -1183,10 +1194,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return;
}
if (nodeData.extendsCredential) {
nodeData.type = getCredentialOnlyNodeTypeName(nodeData.extendsCredential);
}
workflow.value.nodes.push(nodeData);
// Init node metadata
if (!nodeMetadata.value[nodeData.name]) {
@ -1270,12 +1277,12 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
);
}
uiStore.stateIsDirty = true;
updateNodeAtIndex(nodeIndex, {
const changed = updateNodeAtIndex(nodeIndex, {
[updateInformation.key]: updateInformation.value,
});
uiStore.stateIsDirty = uiStore.stateIsDirty || changed;
const excludeKeys = ['position', 'notes', 'notesInFlow'];
if (!excludeKeys.includes(updateInformation.key)) {

View file

@ -138,55 +138,62 @@ function onEdit(id: string) {
</i18n-t>
</n8n-text>
</p>
<template v-if="apiKeysSortByCreationDate.length">
<el-row
v-for="apiKey in apiKeysSortByCreationDate"
:key="apiKey.id"
:gutter="10"
:class="$style.destinationItem"
>
<el-col>
<ApiKeyCard :api-key="apiKey" @delete="onDelete" @edit="onEdit" />
</el-col>
</el-row>
<div v-if="isPublicApiEnabled && apiKeysSortByCreationDate.length" :class="$style.BottomHint">
<N8nText size="small" color="text-light">
{{
i18n.baseText(
`settings.api.view.${settingsStore.isSwaggerUIEnabled ? 'tryapi' : 'more-details'}`,
)
}}
</N8nText>
{{ ' ' }}
<n8n-link
v-if="isSwaggerUIEnabled"
data-test-id="api-playground-link"
:to="apiDocsURL"
:new-window="true"
size="small"
<div :class="$style.apiKeysContainer">
<template v-if="apiKeysSortByCreationDate.length">
<el-row
v-for="(apiKey, index) in apiKeysSortByCreationDate"
:key="apiKey.id"
:gutter="10"
:class="[{ [$style.destinationItem]: index !== apiKeysSortByCreationDate.length - 1 }]"
>
{{ i18n.baseText('settings.api.view.apiPlayground') }}
</n8n-link>
<n8n-link
v-else
data-test-id="api-endpoint-docs-link"
:to="apiDocsURL"
:new-window="true"
size="small"
>
{{ i18n.baseText(`settings.api.view.external-docs`) }}
</n8n-link>
</div>
<div class="mt-m text-right">
<n8n-button size="large" @click="onCreateApiKey">
{{ i18n.baseText('settings.api.create.button') }}
</n8n-button>
</div>
</template>
<el-col>
<ApiKeyCard :api-key="apiKey" @delete="onDelete" @edit="onEdit" />
</el-col>
</el-row>
</template>
</div>
<div v-if="isPublicApiEnabled && apiKeysSortByCreationDate.length" :class="$style.BottomHint">
<N8nText size="small" color="text-light">
{{
i18n.baseText(
`settings.api.view.${settingsStore.isSwaggerUIEnabled ? 'tryapi' : 'more-details'}`,
)
}}
</N8nText>
{{ ' ' }}
<n8n-link
v-if="isSwaggerUIEnabled"
data-test-id="api-playground-link"
:to="apiDocsURL"
:new-window="true"
size="small"
>
{{ i18n.baseText('settings.api.view.apiPlayground') }}
</n8n-link>
<n8n-link
v-else
data-test-id="api-endpoint-docs-link"
:to="apiDocsURL"
:new-window="true"
size="small"
>
{{ i18n.baseText(`settings.api.view.external-docs`) }}
</n8n-link>
</div>
<div class="mt-m text-right">
<n8n-button
v-if="isPublicApiEnabled && apiKeysSortByCreationDate.length"
size="large"
@click="onCreateApiKey"
>
{{ i18n.baseText('settings.api.create.button') }}
</n8n-button>
</div>
<n8n-action-box
v-else-if="!isPublicApiEnabled && cloudPlanStore.userIsTrialing"
v-if="!isPublicApiEnabled && cloudPlanStore.userIsTrialing"
data-test-id="public-api-upgrade-cta"
:heading="i18n.baseText('settings.api.trial.upgradePlan.title')"
:description="i18n.baseText('settings.api.trial.upgradePlan.description')"
@ -246,5 +253,13 @@ function onEdit(id: string) {
.BottomHint {
margin-bottom: var(--spacing-s);
margin-top: var(--spacing-s);
}
.apiKeysContainer {
max-height: 45vh;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
}
</style>

View file

@ -51,7 +51,7 @@ export async function s3ApiRequest(
}
}
endpoint.pathname = path;
endpoint.pathname = `${endpoint.pathname === '/' ? '' : endpoint.pathname}${path}`;
// Sign AWS API request with the user credentials
const signOpts = {
@ -59,7 +59,7 @@ export async function s3ApiRequest(
region: region || credentials.region,
host: endpoint.host,
method,
path: `${path}?${queryToString(query).replace(/\+/g, '%2B')}`,
path: `${endpoint.pathname}?${queryToString(query).replace(/\+/g, '%2B')}`,
service: 's3',
body,
} as Request;

View file

@ -0,0 +1,164 @@
import { sign } from 'aws4';
import { parseString } from 'xml2js';
import {
s3ApiRequest,
s3ApiRequestREST,
s3ApiRequestSOAP,
s3ApiRequestSOAPAllItems,
} from '../GenericFunctions';
jest.mock('aws4');
jest.mock('xml2js');
describe('S3 Node Generic Functions', () => {
let mockContext: any;
beforeEach(() => {
jest.clearAllMocks();
mockContext = {
getNode: jest.fn().mockReturnValue({ name: 'S3' }),
getCredentials: jest.fn().mockResolvedValue({
endpoint: 'https://s3.amazonaws.com',
accessKeyId: 'test-key',
secretAccessKey: 'test-secret',
region: 'us-east-1',
}),
helpers: {
request: jest.fn(),
},
};
});
describe('s3ApiRequest', () => {
it('should throw error if endpoint does not start with http', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
endpoint: 'invalid-endpoint',
});
await expect(s3ApiRequest.call(mockContext, 'test-bucket', 'GET', '/')).rejects.toThrow(
'HTTP(S) Scheme is required',
);
});
it('should handle force path style', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
endpoint: 'https://s3.amazonaws.com',
forcePathStyle: true,
});
mockContext.helpers.request.mockResolvedValueOnce('success');
await s3ApiRequest.call(mockContext, 'test-bucket', 'GET', '/test.txt');
expect(sign).toHaveBeenCalledWith(
expect.objectContaining({
path: '/test-bucket/test.txt?',
}),
expect.any(Object),
);
});
it('should handle supabase url', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
endpoint: 'https://someurl.supabase.co/storage/v1/s3',
region: 'eu-west-2',
forcePathStyle: true,
});
mockContext.helpers.request.mockResolvedValueOnce('success');
await s3ApiRequest.call(mockContext, 'test-bucket', 'GET', '/test.txt');
expect(sign).toHaveBeenCalledWith(
expect.objectContaining({
path: '/storage/v1/s3/test-bucket/test.txt?',
}),
expect.any(Object),
);
});
});
describe('s3ApiRequestREST', () => {
it('should parse JSON response', async () => {
const mockResponse = JSON.stringify({ key: 'value' });
mockContext.helpers.request.mockResolvedValueOnce(mockResponse);
const result = await s3ApiRequestREST.call(mockContext, 'test-bucket', 'GET', '/');
expect(result).toEqual({ key: 'value' });
});
it('should return raw response on parse error', async () => {
const mockResponse = 'invalid-json';
mockContext.helpers.request.mockResolvedValueOnce(mockResponse);
const result = await s3ApiRequestREST.call(mockContext, 'test-bucket', 'GET', '/');
expect(result).toBe('invalid-json');
});
});
describe('s3ApiRequestSOAP', () => {
it('should parse XML response', async () => {
const mockXmlResponse = '<root><key>value</key></root>';
const mockParsedResponse = { root: { key: 'value' } };
mockContext.helpers.request.mockResolvedValueOnce(mockXmlResponse);
(parseString as jest.Mock).mockImplementation((_, __, callback) =>
callback(null, mockParsedResponse),
);
const result = await s3ApiRequestSOAP.call(mockContext, 'test-bucket', 'GET', '/');
expect(result).toEqual(mockParsedResponse);
});
it('should handle XML parsing errors', async () => {
const mockError = new Error('XML Parse Error');
mockContext.helpers.request.mockResolvedValueOnce('<invalid>xml');
(parseString as jest.Mock).mockImplementation((_, __, callback) => callback(mockError));
const result = await s3ApiRequestSOAP.call(mockContext, 'test-bucket', 'GET', '/');
expect(result).toEqual(mockError);
});
});
describe('s3ApiRequestSOAPAllItems', () => {
it('should handle pagination with continuation token', async () => {
const firstResponse = {
ListBucketResult: {
Contents: [{ Key: 'file1.txt' }],
IsTruncated: 'true',
NextContinuationToken: 'token123',
},
};
const secondResponse = {
ListBucketResult: {
Contents: [{ Key: 'file2.txt' }],
IsTruncated: 'false',
},
};
mockContext.helpers.request
.mockResolvedValueOnce('<xml>first</xml>')
.mockResolvedValueOnce('<xml>second</xml>');
(parseString as jest.Mock)
.mockImplementationOnce((_, __, callback) => callback(null, firstResponse))
.mockImplementationOnce((_, __, callback) => callback(null, secondResponse));
const result = await s3ApiRequestSOAPAllItems.call(
mockContext,
'ListBucketResult.Contents',
'test-bucket',
'GET',
'/',
);
expect(result).toHaveLength(2);
expect(result).toEqual([{ Key: 'file1.txt' }, { Key: 'file2.txt' }]);
});
});
});

View file

@ -493,7 +493,7 @@ importers:
version: 3.666.0(@aws-sdk/client-sts@3.666.0)
'@getzep/zep-cloud':
specifier: 1.0.12
version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(c10b80e38f5a8711ccad1e2174de91e6))
version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(101f2d8395211f7761efa8fce29a53de))
'@getzep/zep-js':
specifier: 0.9.0
version: 0.9.0
@ -520,7 +520,7 @@ importers:
version: 0.3.2(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)
'@langchain/community':
specifier: 0.3.24
version: 0.3.24(1ea346ff95b1be1e3f1f4333b25e2811)
version: 0.3.24(6c18f51b4fff56aeb9d6961328b4ede2)
'@langchain/core':
specifier: 'catalog:'
version: 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
@ -610,7 +610,7 @@ importers:
version: 23.0.1
langchain:
specifier: 0.3.11
version: 0.3.11(c10b80e38f5a8711ccad1e2174de91e6)
version: 0.3.11(101f2d8395211f7761efa8fce29a53de)
lodash:
specifier: 'catalog:'
version: 4.17.21
@ -1829,6 +1829,9 @@ importers:
browserslist-to-esbuild:
specifier: ^2.1.1
version: 2.1.1(browserslist@4.24.2)
fake-indexeddb:
specifier: ^6.0.0
version: 6.0.0
miragejs:
specifier: ^0.1.48
version: 0.1.48
@ -8467,6 +8470,10 @@ packages:
resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==}
engines: {'0': node >=0.6.0}
fake-indexeddb@6.0.0:
resolution: {integrity: sha512-YEboHE5VfopUclOck7LncgIqskAqnv4q0EWbYCaxKKjAvO93c+TJIaBuGy8CBFdbg9nKdpN3AuPRwVBJ4k7NrQ==}
engines: {node: '>=18'}
fake-xml-http-request@2.1.2:
resolution: {integrity: sha512-HaFMBi7r+oEC9iJNpc3bvcW7Z7iLmM26hPDmlb0mFwyANSsOQAtJxbdWsXITKOzZUyMYK0zYCv3h5yDj9TsiXg==}
@ -13312,6 +13319,9 @@ packages:
vue-component-type-helpers@2.2.4:
resolution: {integrity: sha512-F66p0XLbAu92BRz6kakHyAcaUSF7HWpWX/THCqL0TxySSj7z/nok5UUMohfNkkCm1pZtawsdzoJ4p1cjNqCx0Q==}
vue-component-type-helpers@2.2.8:
resolution: {integrity: sha512-4bjIsC284coDO9om4HPA62M7wfsTvcmZyzdfR0aUlFXqq4tXxM1APyXpNVxPC8QazKw9OhmZNHBVDA6ODaZsrA==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@ -15922,7 +15932,7 @@ snapshots:
'@gar/promisify@1.1.3':
optional: true
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(c10b80e38f5a8711ccad1e2174de91e6))':
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(101f2d8395211f7761efa8fce29a53de))':
dependencies:
form-data: 4.0.0
node-fetch: 2.7.0(encoding@0.1.13)
@ -15931,7 +15941,7 @@ snapshots:
zod: 3.24.1
optionalDependencies:
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
langchain: 0.3.11(c10b80e38f5a8711ccad1e2174de91e6)
langchain: 0.3.11(101f2d8395211f7761efa8fce29a53de)
transitivePeerDependencies:
- encoding
@ -16450,7 +16460,7 @@ snapshots:
- aws-crt
- encoding
'@langchain/community@0.3.24(1ea346ff95b1be1e3f1f4333b25e2811)':
'@langchain/community@0.3.24(6c18f51b4fff56aeb9d6961328b4ede2)':
dependencies:
'@browserbasehq/stagehand': 1.9.0(@playwright/test@1.49.1)(deepmerge@4.3.1)(dotenv@16.4.5)(encoding@0.1.13)(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))(zod@3.24.1)
'@ibm-cloud/watsonx-ai': 1.1.2
@ -16461,7 +16471,7 @@ snapshots:
flat: 5.0.2
ibm-cloud-sdk-core: 5.1.0
js-yaml: 4.1.0
langchain: 0.3.11(c10b80e38f5a8711ccad1e2174de91e6)
langchain: 0.3.11(101f2d8395211f7761efa8fce29a53de)
langsmith: 0.2.15(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
openai: 4.78.1(encoding@0.1.13)(zod@3.24.1)
uuid: 10.0.0
@ -16476,7 +16486,7 @@ snapshots:
'@aws-sdk/credential-provider-node': 3.666.0(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@aws-sdk/client-sts@3.666.0)
'@azure/storage-blob': 12.18.0(encoding@0.1.13)
'@browserbasehq/sdk': 2.0.0(encoding@0.1.13)
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(c10b80e38f5a8711ccad1e2174de91e6))
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(101f2d8395211f7761efa8fce29a53de))
'@getzep/zep-js': 0.9.0
'@google-ai/generativelanguage': 2.6.0(encoding@0.1.13)
'@google-cloud/storage': 7.12.1(encoding@0.1.13)
@ -18341,7 +18351,7 @@ snapshots:
ts-dedent: 2.2.0
type-fest: 2.19.0
vue: 3.5.13(typescript@5.7.2)
vue-component-type-helpers: 2.2.4
vue-component-type-helpers: 2.2.8
'@supabase/auth-js@2.65.0':
dependencies:
@ -19793,14 +19803,6 @@ snapshots:
transitivePeerDependencies:
- debug
axios@1.7.4(debug@4.4.0):
dependencies:
follow-redirects: 1.15.6(debug@4.4.0)
form-data: 4.0.0
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axios@1.7.7:
dependencies:
follow-redirects: 1.15.6(debug@4.3.6)
@ -21465,7 +21467,7 @@ snapshots:
eslint-import-resolver-node@0.3.9:
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
is-core-module: 2.13.1
resolve: 1.22.8
transitivePeerDependencies:
@ -21490,7 +21492,7 @@ snapshots:
eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
optionalDependencies:
'@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.7.2)
eslint: 8.57.0
@ -21510,7 +21512,7 @@ snapshots:
array.prototype.findlastindex: 1.2.3
array.prototype.flat: 1.3.2
array.prototype.flatmap: 1.3.2
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
@ -21857,6 +21859,8 @@ snapshots:
extsprintf@1.3.0: {}
fake-indexeddb@6.0.0: {}
fake-xml-http-request@2.1.2: {}
fast-deep-equal@3.1.3: {}
@ -22001,10 +22005,6 @@ snapshots:
optionalDependencies:
debug: 4.3.7
follow-redirects@1.15.6(debug@4.4.0):
optionalDependencies:
debug: 4.4.0(supports-color@8.1.1)
for-each@0.3.3:
dependencies:
is-callable: 1.2.7
@ -22294,7 +22294,7 @@ snapshots:
array-parallel: 0.1.3
array-series: 0.1.5
cross-spawn: 4.0.2
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -22596,7 +22596,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 18.16.16
'@types/tough-cookie': 4.0.2
axios: 1.7.4(debug@4.4.0)
axios: 1.7.4
camelcase: 6.3.0
debug: 4.4.0(supports-color@8.1.1)
dotenv: 16.4.5
@ -22606,7 +22606,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.2
mime-types: 2.1.35
retry-axios: 2.6.0(axios@1.7.4)
retry-axios: 2.6.0(axios@1.7.4(debug@4.4.0))
tough-cookie: 4.1.3
transitivePeerDependencies:
- supports-color
@ -23593,7 +23593,7 @@ snapshots:
kuler@2.0.0: {}
langchain@0.3.11(c10b80e38f5a8711ccad1e2174de91e6):
langchain@0.3.11(101f2d8395211f7761efa8fce29a53de):
dependencies:
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
'@langchain/openai': 0.3.17(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)
@ -25157,7 +25157,7 @@ snapshots:
pdf-parse@1.1.1:
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
node-ensure: 0.0.0
transitivePeerDependencies:
- supports-color
@ -25965,7 +25965,7 @@ snapshots:
ret@0.1.15: {}
retry-axios@2.6.0(axios@1.7.4):
retry-axios@2.6.0(axios@1.7.4(debug@4.4.0)):
dependencies:
axios: 1.7.4
@ -25992,7 +25992,7 @@ snapshots:
rhea@1.0.24:
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -27624,6 +27624,8 @@ snapshots:
vue-component-type-helpers@2.2.4: {}
vue-component-type-helpers@2.2.8: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.7.2)):
dependencies:
vue: 3.5.13(typescript@5.7.2)