mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge branch 'master' into ADO-3066-show-errors-in-rlc
This commit is contained in:
commit
c09bfa0940
1
.github/workflows/release-publish.yml
vendored
1
.github/workflows/release-publish.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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 && \
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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) {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -300,6 +300,12 @@ async function onConnectionStateChange() {
|
|||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
|
||||
svg,
|
||||
img {
|
||||
max-width: 28px;
|
||||
max-height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.providerActions {
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -1827,6 +1827,7 @@ defineExpose({ enterEditMode });
|
|||
:mapping-enabled="mappingEnabled"
|
||||
:distance-from-active="distanceFromActive"
|
||||
:run-index="runIndex"
|
||||
:output-index="currentOutputIndex"
|
||||
:total-runs="maxRunIndex"
|
||||
:search="search"
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"] }}');
|
||||
});
|
||||
});
|
|
@ -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')"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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=""
|
||||
>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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/';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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()');
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
164
packages/nodes-base/nodes/S3/__tests__/GenericFunctions.test.ts
Normal file
164
packages/nodes-base/nodes/S3/__tests__/GenericFunctions.test.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue