mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Expose View Execution
links for erroneous sub-executions (#13185)
This commit is contained in:
parent
85deff0b7f
commit
11cf1cd23a
|
@ -14,6 +14,7 @@ import {
|
||||||
type INodeType,
|
type INodeType,
|
||||||
type INodeTypeDescription,
|
type INodeTypeDescription,
|
||||||
NodeOperationError,
|
NodeOperationError,
|
||||||
|
parseErrorMetadata,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { promptTypeOptions, textFromPreviousNode } from '@utils/descriptions';
|
import { promptTypeOptions, textFromPreviousNode } from '@utils/descriptions';
|
||||||
|
@ -229,7 +230,12 @@ export class ChainRetrievalQa implements INodeType {
|
||||||
returnData.push({ json: { response } });
|
returnData.push({ json: { response } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.continueOnFail()) {
|
if (this.continueOnFail()) {
|
||||||
returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } });
|
const metadata = parseErrorMetadata(error);
|
||||||
|
returnData.push({
|
||||||
|
json: { error: error.message },
|
||||||
|
pairedItem: { item: itemIndex },
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ import {
|
||||||
jsonParse,
|
jsonParse,
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
NodeOperationError,
|
NodeOperationError,
|
||||||
|
parseErrorMetadata,
|
||||||
traverseNodeParameters,
|
traverseNodeParameters,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
@ -92,7 +93,9 @@ export class WorkflowToolService {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const executionError = error as ExecutionError;
|
const executionError = error as ExecutionError;
|
||||||
const errorResponse = `There was an error: "${executionError.message}"`;
|
const errorResponse = `There was an error: "${executionError.message}"`;
|
||||||
void this.context.addOutputData(NodeConnectionType.AiTool, index, executionError);
|
|
||||||
|
const metadata = parseErrorMetadata(error);
|
||||||
|
void this.context.addOutputData(NodeConnectionType.AiTool, index, executionError, metadata);
|
||||||
return errorResponse;
|
return errorResponse;
|
||||||
} finally {
|
} finally {
|
||||||
// @ts-expect-error this accesses a private member on the actual implementation to fix https://linear.app/n8n/issue/ADO-3186/bug-workflowtool-v2-always-uses-first-row-of-input-data
|
// @ts-expect-error this accesses a private member on the actual implementation to fix https://linear.app/n8n/issue/ADO-3186/bug-workflowtool-v2-always-uses-first-row-of-input-data
|
||||||
|
|
|
@ -16,7 +16,7 @@ import type {
|
||||||
ISupplyDataFunctions,
|
ISupplyDataFunctions,
|
||||||
ITaskMetadata,
|
ITaskMetadata,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeOperationError, NodeConnectionType } from 'n8n-workflow';
|
import { NodeOperationError, NodeConnectionType, parseErrorMetadata } from 'n8n-workflow';
|
||||||
|
|
||||||
import { logAiEvent, isToolsInstance, isBaseChatMemory, isBaseChatMessageHistory } from './helpers';
|
import { logAiEvent, isToolsInstance, isBaseChatMemory, isBaseChatMessageHistory } from './helpers';
|
||||||
import { N8nBinaryLoader } from './N8nBinaryLoader';
|
import { N8nBinaryLoader } from './N8nBinaryLoader';
|
||||||
|
@ -41,10 +41,12 @@ export async function callMethodAsync<T>(
|
||||||
functionality: 'configuration-node',
|
functionality: 'configuration-node',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const metadata = parseErrorMetadata(error);
|
||||||
parameters.executeFunctions.addOutputData(
|
parameters.executeFunctions.addOutputData(
|
||||||
parameters.connectionType,
|
parameters.connectionType,
|
||||||
parameters.currentNodeRunIndex,
|
parameters.currentNodeRunIndex,
|
||||||
error,
|
error,
|
||||||
|
metadata,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error.message) {
|
if (error.message) {
|
||||||
|
|
|
@ -295,6 +295,8 @@ async function startExecution(
|
||||||
throw objectToError(
|
throw objectToError(
|
||||||
{
|
{
|
||||||
...executionError,
|
...executionError,
|
||||||
|
executionId,
|
||||||
|
workflowId: workflowData.id,
|
||||||
stack: executionError?.stack,
|
stack: executionError?.stack,
|
||||||
message: executionError?.message,
|
message: executionError?.message,
|
||||||
},
|
},
|
||||||
|
@ -322,6 +324,8 @@ async function startExecution(
|
||||||
throw objectToError(
|
throw objectToError(
|
||||||
{
|
{
|
||||||
...error,
|
...error,
|
||||||
|
executionId,
|
||||||
|
workflowId: workflowData.id,
|
||||||
stack: error?.stack,
|
stack: error?.stack,
|
||||||
},
|
},
|
||||||
workflow,
|
workflow,
|
||||||
|
|
|
@ -387,6 +387,57 @@ describe('RunData', () => {
|
||||||
expect(trackOpeningRelatedExecution).toHaveBeenCalledWith(metadata, 'json');
|
expect(trackOpeningRelatedExecution).toHaveBeenCalledWith(metadata, 'json');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render sub-execution link in header with sub-node error', async () => {
|
||||||
|
const metadata = {
|
||||||
|
subExecution: {
|
||||||
|
workflowId: 'xyz',
|
||||||
|
executionId: '123',
|
||||||
|
},
|
||||||
|
subExecutionsCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByTestId } = render({
|
||||||
|
defaultRunItems: [
|
||||||
|
{
|
||||||
|
json: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
displayMode: 'table',
|
||||||
|
paneType: 'output',
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
hints: [],
|
||||||
|
startTime: 1737643696893,
|
||||||
|
executionTime: 2,
|
||||||
|
source: [
|
||||||
|
{
|
||||||
|
previousNode: 'When clicking ‘Test workflow’',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
executionStatus: 'error',
|
||||||
|
error: {
|
||||||
|
level: 'error',
|
||||||
|
errorResponse: {
|
||||||
|
...metadata.subExecution,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('related-execution-link')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('related-execution-link')).toHaveTextContent('View sub-execution');
|
||||||
|
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
|
||||||
|
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
|
||||||
|
|
||||||
|
expect(getByTestId('ndv-items-count')).toHaveTextContent(
|
||||||
|
'1 item, 1 sub-execution View sub-execution',
|
||||||
|
);
|
||||||
|
|
||||||
|
getByTestId('related-execution-link').click();
|
||||||
|
expect(trackOpeningRelatedExecution).toHaveBeenCalledWith(metadata, 'table');
|
||||||
|
});
|
||||||
|
|
||||||
it('should render input selector when input node has error', async () => {
|
it('should render input selector when input node has error', async () => {
|
||||||
const testNodes = [
|
const testNodes = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -12,8 +12,9 @@ import {
|
||||||
type ITaskMetadata,
|
type ITaskMetadata,
|
||||||
type NodeError,
|
type NodeError,
|
||||||
type NodeHint,
|
type NodeHint,
|
||||||
type Workflow,
|
|
||||||
TRIMMED_TASK_DATA_CONNECTIONS_KEY,
|
TRIMMED_TASK_DATA_CONNECTIONS_KEY,
|
||||||
|
type Workflow,
|
||||||
|
parseErrorMetadata,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
|
import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
|
||||||
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue';
|
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue';
|
||||||
|
@ -300,6 +301,12 @@ const subworkflowExecutionError = computed(() => {
|
||||||
const hasSubworkflowExecutionError = computed(() =>
|
const hasSubworkflowExecutionError = computed(() =>
|
||||||
Boolean(workflowsStore.subWorkflowExecutionError),
|
Boolean(workflowsStore.subWorkflowExecutionError),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Sub-nodes may wish to display the parent node error as it can contain additional metadata
|
||||||
|
const parentNodeError = computed(() => {
|
||||||
|
const parentNode = props.workflow.getChildNodes(node.value?.name ?? '', 'ALL_NON_MAIN')[0];
|
||||||
|
return workflowRunData.value?.[parentNode]?.[props.runIndex]?.error as NodeError;
|
||||||
|
});
|
||||||
const workflowRunErrorAsNodeError = computed(() => {
|
const workflowRunErrorAsNodeError = computed(() => {
|
||||||
if (!node.value) {
|
if (!node.value) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -307,8 +314,7 @@ const workflowRunErrorAsNodeError = computed(() => {
|
||||||
|
|
||||||
// If the node is a sub-node, we need to get the parent node error to check for input errors
|
// If the node is a sub-node, we need to get the parent node error to check for input errors
|
||||||
if (isSubNodeType.value && props.paneType === 'input') {
|
if (isSubNodeType.value && props.paneType === 'input') {
|
||||||
const parentNode = props.workflow.getChildNodes(node.value?.name ?? '', 'ALL_NON_MAIN')[0];
|
return parentNodeError.value;
|
||||||
return workflowRunData.value?.[parentNode]?.[props.runIndex]?.error as NodeError;
|
|
||||||
}
|
}
|
||||||
return workflowRunData.value?.[node.value?.name]?.[props.runIndex]?.error as NodeError;
|
return workflowRunData.value?.[node.value?.name]?.[props.runIndex]?.error as NodeError;
|
||||||
});
|
});
|
||||||
|
@ -533,13 +539,25 @@ const activeTaskMetadata = computed((): ITaskMetadata | null => {
|
||||||
if (!node.value) {
|
if (!node.value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const errorMetadata = parseErrorMetadata(workflowRunErrorAsNodeError.value);
|
||||||
|
if (errorMetadata !== undefined) {
|
||||||
|
return errorMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is needed for the WorkflowRetriever to display the associated execution
|
||||||
|
if (parentNodeError.value) {
|
||||||
|
const subNodeMetadata = parseErrorMetadata(parentNodeError.value);
|
||||||
|
if (subNodeMetadata !== undefined) {
|
||||||
|
return subNodeMetadata;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return workflowRunData.value?.[node.value.name]?.[props.runIndex]?.metadata ?? null;
|
return workflowRunData.value?.[node.value.name]?.[props.runIndex]?.metadata ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasReleatedExectuion = computed((): boolean => {
|
const hasRelatedExecution = computed(() => {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
activeTaskMetadata.value?.subExecution || activeTaskMetadata.value?.parentExecution,
|
activeTaskMetadata.value?.subExecution ?? activeTaskMetadata.value?.parentExecution,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1483,7 +1501,7 @@ defineExpose({ enterEditMode });
|
||||||
|
|
||||||
<a
|
<a
|
||||||
v-if="
|
v-if="
|
||||||
activeTaskMetadata && hasReleatedExectuion && !(paneType === 'input' && hasInputOverwrite)
|
activeTaskMetadata && hasRelatedExecution && !(paneType === 'input' && hasInputOverwrite)
|
||||||
"
|
"
|
||||||
:class="$style.relatedExecutionInfo"
|
:class="$style.relatedExecutionInfo"
|
||||||
data-test-id="related-execution-link"
|
data-test-id="related-execution-link"
|
||||||
|
@ -1573,7 +1591,7 @@ defineExpose({ enterEditMode });
|
||||||
|
|
||||||
<a
|
<a
|
||||||
v-if="
|
v-if="
|
||||||
activeTaskMetadata && hasReleatedExectuion && !(paneType === 'input' && hasInputOverwrite)
|
activeTaskMetadata && hasRelatedExecution && !(paneType === 'input' && hasInputOverwrite)
|
||||||
"
|
"
|
||||||
:class="$style.relatedExecutionInfo"
|
:class="$style.relatedExecutionInfo"
|
||||||
data-test-id="related-execution-link"
|
data-test-id="related-execution-link"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
import { NodeConnectionType, NodeOperationError, parseErrorMetadata } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
ExecuteWorkflowData,
|
ExecuteWorkflowData,
|
||||||
IExecuteFunctions,
|
IExecuteFunctions,
|
||||||
|
@ -369,7 +369,12 @@ export class ExecuteWorkflow implements INodeType {
|
||||||
if (returnData[i] === undefined) {
|
if (returnData[i] === undefined) {
|
||||||
returnData[i] = [];
|
returnData[i] = [];
|
||||||
}
|
}
|
||||||
returnData[i].push({ json: { error: error.message }, pairedItem: { item: i } });
|
const metadata = parseErrorMetadata(error);
|
||||||
|
returnData[i].push({
|
||||||
|
json: { error: error.message },
|
||||||
|
pairedItem: { item: i },
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new NodeOperationError(this.getNode(), error, {
|
throw new NodeOperationError(this.getNode(), error, {
|
||||||
|
@ -436,7 +441,15 @@ export class ExecuteWorkflow implements INodeType {
|
||||||
return workflowResult;
|
return workflowResult;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.continueOnFail()) {
|
if (this.continueOnFail()) {
|
||||||
return [[{ json: { error: error.message } }]];
|
const metadata = parseErrorMetadata(error);
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
json: { error: error.message },
|
||||||
|
metadata,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
31
packages/workflow/src/MetadataUtils.ts
Normal file
31
packages/workflow/src/MetadataUtils.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import type { ITaskMetadata } from '.';
|
||||||
|
import { hasKey } from './utils';
|
||||||
|
|
||||||
|
function responseHasSubworkflowData(
|
||||||
|
response: unknown,
|
||||||
|
): response is { executionId: string; workflowId: string } {
|
||||||
|
return ['executionId', 'workflowId'].every(
|
||||||
|
(x) => hasKey(response, x) && typeof response[x] === 'string',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ISubWorkflowMetadata = Required<Pick<ITaskMetadata, 'subExecution' | 'subExecutionsCount'>>;
|
||||||
|
|
||||||
|
function parseErrorResponseWorkflowMetadata(response: unknown): ISubWorkflowMetadata | undefined {
|
||||||
|
if (!responseHasSubworkflowData(response)) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
subExecution: {
|
||||||
|
executionId: response.executionId,
|
||||||
|
workflowId: response.workflowId,
|
||||||
|
},
|
||||||
|
subExecutionsCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseErrorMetadata(error: unknown): ISubWorkflowMetadata | undefined {
|
||||||
|
if (hasKey(error, 'errorResponse')) {
|
||||||
|
return parseErrorResponseWorkflowMetadata(error.errorResponse);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ import type {
|
||||||
IDataObject,
|
IDataObject,
|
||||||
IStatusCodeMessages,
|
IStatusCodeMessages,
|
||||||
Functionality,
|
Functionality,
|
||||||
|
RelatedExecution,
|
||||||
} from '../Interfaces';
|
} from '../Interfaces';
|
||||||
import { removeCircularRefs } from '../utils';
|
import { removeCircularRefs } from '../utils';
|
||||||
|
|
||||||
|
@ -30,6 +31,10 @@ export interface NodeOperationErrorOptions {
|
||||||
messageMapping?: { [key: string]: string }; // allows to pass custom mapping for error messages scoped to a node
|
messageMapping?: { [key: string]: string }; // allows to pass custom mapping for error messages scoped to a node
|
||||||
functionality?: Functionality;
|
functionality?: Functionality;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
metadata?: {
|
||||||
|
subExecution?: RelatedExecution;
|
||||||
|
parentExecution?: RelatedExecution;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeApiErrorOptions extends NodeOperationErrorOptions {
|
interface NodeApiErrorOptions extends NodeOperationErrorOptions {
|
||||||
|
|
|
@ -35,6 +35,7 @@ export class NodeOperationError extends NodeError {
|
||||||
this.description = options.description;
|
this.description = options.description;
|
||||||
this.context.runIndex = options.runIndex;
|
this.context.runIndex = options.runIndex;
|
||||||
this.context.itemIndex = options.itemIndex;
|
this.context.itemIndex = options.itemIndex;
|
||||||
|
this.context.metadata = options.metadata;
|
||||||
|
|
||||||
if (this.message === this.description) {
|
if (this.message === this.description) {
|
||||||
this.description = undefined;
|
this.description = undefined;
|
||||||
|
|
|
@ -15,6 +15,7 @@ export * from './ExecutionStatus';
|
||||||
export * from './Expression';
|
export * from './Expression';
|
||||||
export * from './FromAIParseUtils';
|
export * from './FromAIParseUtils';
|
||||||
export * from './NodeHelpers';
|
export * from './NodeHelpers';
|
||||||
|
export * from './MetadataUtils';
|
||||||
export * from './Workflow';
|
export * from './Workflow';
|
||||||
export * from './WorkflowDataProxy';
|
export * from './WorkflowDataProxy';
|
||||||
export * from './WorkflowDataProxyEnvProvider';
|
export * from './WorkflowDataProxyEnvProvider';
|
||||||
|
|
|
@ -276,3 +276,10 @@ export function randomString(minLength: number, maxLength?: number): string {
|
||||||
.map((byte) => ALPHABET[byte % ALPHABET.length])
|
.map((byte) => ALPHABET[byte % ALPHABET.length])
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a value is an object with a specific key and provides a type guard for the key.
|
||||||
|
*/
|
||||||
|
export function hasKey<T extends PropertyKey>(value: unknown, key: T): value is Record<T, unknown> {
|
||||||
|
return value !== null && typeof value === 'object' && value.hasOwnProperty(key);
|
||||||
|
}
|
||||||
|
|
30
packages/workflow/test/MetadataUtils.test.ts
Normal file
30
packages/workflow/test/MetadataUtils.test.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { parseErrorMetadata } from '@/MetadataUtils';
|
||||||
|
|
||||||
|
describe('MetadataUtils', () => {
|
||||||
|
describe('parseMetadataFromError', () => {
|
||||||
|
it('should return undefined if error does not have response', () => {
|
||||||
|
const error = { message: 'An error occurred' };
|
||||||
|
const result = parseErrorMetadata(error);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined if error response does not have subworkflow data', () => {
|
||||||
|
const error = { errorResponse: { someKey: 'someValue' } };
|
||||||
|
const result = parseErrorMetadata(error);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return metadata if error response has subworkflow data', () => {
|
||||||
|
const error = { errorResponse: { executionId: '123', workflowId: '456' } };
|
||||||
|
const expectedMetadata = {
|
||||||
|
subExecution: {
|
||||||
|
executionId: '123',
|
||||||
|
workflowId: '456',
|
||||||
|
},
|
||||||
|
subExecutionsCount: 1,
|
||||||
|
};
|
||||||
|
const result = parseErrorMetadata(error);
|
||||||
|
expect(result).toEqual(expectedMetadata);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -8,6 +8,7 @@ import {
|
||||||
fileTypeFromMimeType,
|
fileTypeFromMimeType,
|
||||||
randomInt,
|
randomInt,
|
||||||
randomString,
|
randomString,
|
||||||
|
hasKey,
|
||||||
} from '@/utils';
|
} from '@/utils';
|
||||||
|
|
||||||
describe('isObjectEmpty', () => {
|
describe('isObjectEmpty', () => {
|
||||||
|
@ -290,3 +291,78 @@ describe('randomString', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type Expect<T extends true> = T;
|
||||||
|
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
|
||||||
|
? true
|
||||||
|
: false;
|
||||||
|
|
||||||
|
describe('hasKey', () => {
|
||||||
|
it('should return false if the input is null', () => {
|
||||||
|
const x = null;
|
||||||
|
const result = hasKey(x, 'key');
|
||||||
|
|
||||||
|
expect(result).toEqual(false);
|
||||||
|
});
|
||||||
|
it('should return false if the input is undefined', () => {
|
||||||
|
const x = undefined;
|
||||||
|
const result = hasKey(x, 'key');
|
||||||
|
|
||||||
|
expect(result).toEqual(false);
|
||||||
|
});
|
||||||
|
it('should return false if the input is a number', () => {
|
||||||
|
const x = 1;
|
||||||
|
const result = hasKey(x, 'key');
|
||||||
|
|
||||||
|
expect(result).toEqual(false);
|
||||||
|
});
|
||||||
|
it('should return false if the input is an array out of bounds', () => {
|
||||||
|
const x = [1, 2];
|
||||||
|
const result = hasKey(x, 5);
|
||||||
|
|
||||||
|
expect(result).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true if the input is an array within bounds', () => {
|
||||||
|
const x = [1, 2];
|
||||||
|
const result = hasKey(x, 1);
|
||||||
|
|
||||||
|
expect(result).toEqual(true);
|
||||||
|
});
|
||||||
|
it('should return true if the input is an array with the key `length`', () => {
|
||||||
|
const x = [1, 2];
|
||||||
|
const result = hasKey(x, 'length');
|
||||||
|
|
||||||
|
expect(result).toEqual(true);
|
||||||
|
});
|
||||||
|
it('should return false if the input is an array with the key `toString`', () => {
|
||||||
|
const x = [1, 2];
|
||||||
|
const result = hasKey(x, 'toString');
|
||||||
|
|
||||||
|
expect(result).toEqual(false);
|
||||||
|
});
|
||||||
|
it('should return false if the input is an object without the key', () => {
|
||||||
|
const x = { a: 3 };
|
||||||
|
const result = hasKey(x, 'a');
|
||||||
|
|
||||||
|
expect(result).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true if the input is an object with the key', () => {
|
||||||
|
const x = { a: 3 };
|
||||||
|
const result = hasKey(x, 'b');
|
||||||
|
|
||||||
|
expect(result).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide a type guard', () => {
|
||||||
|
const x: unknown = { a: 3 };
|
||||||
|
if (hasKey(x, '0')) {
|
||||||
|
const y: Expect<Equal<typeof x, Record<'0', unknown>>> = true;
|
||||||
|
y;
|
||||||
|
} else {
|
||||||
|
const z: Expect<Equal<typeof x, unknown>> = true;
|
||||||
|
z;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
Loading…
Reference in a new issue