From d80bd91c8204b5b1c40ed881b8e1b04ae0ee59d3 Mon Sep 17 00:00:00 2001 From: Adam Petro Date: Fri, 7 Nov 2025 16:13:01 -0500 Subject: [PATCH 1/2] Add path to input validation error --- src/methods/validate-fixture-input.ts | 156 +++++--- src/methods/validate-test-assets.ts | 7 +- test/methods/validate-fixture-input.test.ts | 380 ++++++++++++-------- test/methods/validate-test-assets.test.ts | 43 ++- 4 files changed, 355 insertions(+), 231 deletions(-) diff --git a/src/methods/validate-fixture-input.ts b/src/methods/validate-fixture-input.ts index 3249c58..451df0c 100644 --- a/src/methods/validate-fixture-input.ts +++ b/src/methods/validate-fixture-input.ts @@ -19,12 +19,23 @@ import { visit, visitWithTypeInfo, BREAK, + ASTNode, } from "graphql"; import { inlineNamedFragmentSpreads } from "../utils/inline-named-fragment-spreads.js"; +export interface FixtureInputValidationError { + message: string; + path: (string | number)[]; +} + export interface ValidateFixtureInputResult { - errors: string[]; + errors: FixtureInputValidationError[]; +} + +interface NestedValue { + value: any; + path: (string | number)[]; } /** @@ -46,7 +57,7 @@ export function validateFixtureInput( ): ValidateFixtureInputResult { const inlineFragmentSpreadsAst = inlineNamedFragmentSpreads(queryAST); const typeInfo = new TypeInfo(schema); - const valueStack: any[][] = [[value]]; + const valueStack: NestedValue[][] = [[{ value, path: [] }]]; const typeStack: (GraphQLNamedType | undefined)[] = []; const possibleTypesStack: Set[] = [ new Set([schema.getQueryType()!.name]), @@ -54,7 +65,7 @@ export function validateFixtureInput( const typenameResponseKeyStack: (string | undefined)[] = []; const expectedFieldsStack: Map>[] = [new Map()]; - const errors: string[] = []; + const errors: FixtureInputValidationError[] = []; visit( inlineFragmentSpreadsAst, @@ -87,7 +98,7 @@ export function validateFixtureInput( }, }, Field: { - enter(node) { + enter(node, _key, _parent, _path, ancestors) { const currentValues = valueStack[valueStack.length - 1]; const nestedValues = []; @@ -108,14 +119,19 @@ export function validateFixtureInput( const fieldDefinition = typeInfo.getFieldDef(); if (fieldDefinition === undefined || fieldDefinition === null) { - errors.push( - `Cannot validate ${responseKey}: missing field definition`, - ); + const currentPath = pathFromAncestors(ancestors); + errors.push({ + message: `Cannot validate ${responseKey}: missing field definition`, + path: [...currentPath, responseKey], + }); return BREAK; } const fieldType = fieldDefinition.type; - for (const currentValue of currentValues) { + for (const { + value: currentValue, + path: currentPath, + } of currentValues) { const valueForResponseKey = currentValue[responseKey]; // Field is missing from fixture @@ -134,7 +150,10 @@ export function validateFixtureInput( typenameResponseKey, ) ) { - errors.push(`Missing expected fixture data for ${responseKey}`); + errors.push({ + message: `Missing expected fixture data for ${responseKey}`, + path: [...currentPath, responseKey], + }); } } // Scalars and Enums (including wrapped types) @@ -146,8 +165,11 @@ export function validateFixtureInput( coerceInputValue( valueForResponseKey, fieldType, - (path, _invalidValue, error) => { - errors.push(`${error.message} At "${path.join(".")}"`); + (_path, _invalidValue, error) => { + errors.push({ + message: error.message, + path: [...currentPath, responseKey], + }); }, ); } @@ -169,14 +191,15 @@ export function validateFixtureInput( processNestedArrays( valueForResponseKey, unwrappedFieldType, - responseKey, + [...currentPath, responseKey], ); nestedValues.push(...flattened); errors.push(...flattenErrors); } else { - errors.push( - `Expected array for ${responseKey}, but got ${typeof valueForResponseKey}`, - ); + errors.push({ + message: `Expected array, but got ${typeof valueForResponseKey}`, + path: [...currentPath, responseKey], + }); } } // Objects - validate and add to traversal stack @@ -185,29 +208,36 @@ export function validateFixtureInput( isAbstractType(unwrappedFieldType) ) { if (valueForResponseKey === null) { - errors.push( - `Expected object for ${responseKey}, but got null`, - ); + errors.push({ + message: `Expected object, but got null`, + path: [...currentPath, responseKey], + }); } else if (typeof valueForResponseKey === "object") { - nestedValues.push(valueForResponseKey); + nestedValues.push({ + value: valueForResponseKey, + path: [...currentPath, responseKey], + }); } else { - errors.push( - `Expected object for ${responseKey}, but got ${typeof valueForResponseKey}`, - ); + errors.push({ + message: `Expected object, but got ${typeof valueForResponseKey}`, + path: [...currentPath, responseKey], + }); } } // Unexpected type - defensive check that should never be reached else { - errors.push( - `Unexpected type for ${responseKey}: ${unwrappedFieldType}`, - ); + errors.push({ + message: `Unexpected type, expected ${unwrappedFieldType}`, + path: [...currentPath, responseKey], + }); } } // No type information - should not happen with valid query else { - errors.push( - `Cannot validate ${responseKey}: missing type information`, - ); + errors.push({ + message: `Cannot validate ${responseKey}: missing type information`, + path: [...currentPath, responseKey], + }); } } @@ -236,7 +266,7 @@ export function validateFixtureInput( }, }, SelectionSet: { - enter(node, _key, parent) { + enter(node, _key, parent, _path, ancestors) { // If this SelectionSet belongs to a Field, prepare to track expected fields if (parent && "kind" in parent && parent.kind === Kind.FIELD) { expectedFieldsStack.push(new Map()); @@ -281,9 +311,11 @@ export function validateFixtureInput( ).length; if (!hasTypename && fragmentSpreadCount > 1) { - errors.push( - `Missing __typename field for abstract type ${getNamedType(typeInfo.getType())?.name}`, - ); + const currentPath = pathFromAncestors([...ancestors, parent!]); + errors.push({ + message: `Missing __typename field for abstract type ${getNamedType(typeInfo.getType())?.name}`, + path: currentPath, + }); return BREAK; } } @@ -348,42 +380,43 @@ export function validateFixtureInput( function processNestedArrays( value: any[], listType: GraphQLList, - fieldName: string, -): { values: any[]; errors: string[] } { - const result: any[] = []; - const errors: string[] = []; + path: (string | number)[], +): { values: NestedValue[]; errors: FixtureInputValidationError[] } { + const nestedValues: NestedValue[] = []; + const errors: FixtureInputValidationError[] = []; const elementType = listType.ofType; for (const [index, element] of value.entries()) { if (element === null) { if (!isNullableType(elementType)) { - errors.push( - `Null value found in non-nullable array at ${fieldName}[${index}]`, - ); + errors.push({ + message: "Null value found in non-nullable array", + path: [...path, index], + }); } } else if (isListType(elementType)) { // Element type is a list - expect nested array and recurse if (Array.isArray(element)) { - const nested = processNestedArrays( - element, - elementType, - `${fieldName}[${index}]`, - ); - result.push(...nested.values); + const nested = processNestedArrays(element, elementType, [ + ...path, + index, + ]); + nestedValues.push(...nested.values); errors.push(...nested.errors); } else { // Error: fixture structure doesn't match schema nesting - errors.push( - `Expected array at ${fieldName}[${index}], but got ${typeof element}`, - ); + errors.push({ + message: `Expected array, but got ${typeof element}`, + path: [...path, index], + }); } } else { // Non-list type - add directly - result.push(element); + nestedValues.push({ value: element, path: [...path, index] }); } } - return { values: result, errors }; + return { values: nestedValues, errors }; } /** @@ -461,13 +494,13 @@ function isValueExpectedForType( * - No schema lookups needed - possible types were pre-computed during traversal */ function checkForExtraFields( - fixtureObjects: any[], + fixtureObjects: NestedValue[], expectedFields: Map>, typenameResponseKey: string | undefined, -): string[] { - const errors: string[] = []; +): FixtureInputValidationError[] { + const errors: FixtureInputValidationError[] = []; - for (const fixtureObject of fixtureObjects) { + for (const { value: fixtureObject, path } of fixtureObjects) { if ( typeof fixtureObject === "object" && fixtureObject !== null && @@ -502,9 +535,10 @@ function checkForExtraFields( // Check each field in the fixture object for (const fixtureField of fixtureFields) { if (!expectedForThisObject.has(fixtureField)) { - errors.push( - `Extra field "${fixtureField}" found in fixture data not in query`, - ); + errors.push({ + message: `Extra field "${fixtureField}" found in fixture data not in query`, + path: [...path, fixtureField], + }); } } } @@ -512,3 +546,11 @@ function checkForExtraFields( return errors; } + +function pathFromAncestors( + ancestors: ReadonlyArray>, +): (string | number)[] { + return ancestors + .filter((ancestor) => "kind" in ancestor && ancestor.kind === Kind.FIELD) + .map((ancestor) => ancestor.alias?.value || ancestor.name.value); +} diff --git a/src/methods/validate-test-assets.ts b/src/methods/validate-test-assets.ts index 471459f..9bdb74f 100644 --- a/src/methods/validate-test-assets.ts +++ b/src/methods/validate-test-assets.ts @@ -4,7 +4,10 @@ import { determineMutationFromTarget } from "../utils/determine-mutation-from-ta import { validateInputQuery } from "./validate-input-query.js"; import { validateFixtureOutput } from "./validate-fixture-output.js"; -import { validateFixtureInput } from "./validate-fixture-input.js"; +import { + validateFixtureInput, + FixtureInputValidationError, +} from "./validate-fixture-input.js"; import { FixtureData } from "./load-fixture.js"; /** @@ -28,7 +31,7 @@ export interface CompleteValidationResult { errors: ReadonlyArray; }; inputFixture: { - errors: string[]; + errors: FixtureInputValidationError[]; }; outputFixture: { errors: { message: string }[]; diff --git a/test/methods/validate-fixture-input.test.ts b/test/methods/validate-fixture-input.test.ts index d2313c3..b5e641d 100644 --- a/test/methods/validate-fixture-input.test.ts +++ b/test/methods/validate-fixture-input.test.ts @@ -254,9 +254,10 @@ describe("validateFixtureInput", () => { // - inner SelectionSet has typenameResponseKey = undefined (doesn't inherit "outerType") // - Detects missing __typename and BREAKs early expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe( - "Missing __typename field for abstract type NestedInner", - ); + expect(result.errors[0]).toStrictEqual({ + message: "Missing __typename field for abstract type NestedInner", + path: ["data", "nested", "inner"], + }); }); it("handles nested unions with typename at each level", () => { @@ -646,13 +647,18 @@ describe("validateFixtureInput", () => { // (due to type not implementing nested interfaces) or invalid (incomplete data) // So it conservatively expects all selected fields on non-empty objects expect(result.errors).toHaveLength(3); - expect(result.errors[0]).toBe("Missing expected fixture data for name"); - expect(result.errors[1]).toBe( - "Missing expected fixture data for description", - ); - expect(result.errors[2]).toBe( - "Missing expected fixture data for description", - ); + expect(result.errors[0]).toStrictEqual({ + message: "Missing expected fixture data for name", + path: ["data", "interfaceImplementers", 2, "name"], + }); + expect(result.errors[1]).toStrictEqual({ + message: "Missing expected fixture data for description", + path: ["data", "interfaceImplementers", 1, "description"], + }); + expect(result.errors[2]).toStrictEqual({ + message: "Missing expected fixture data for description", + path: ["data", "interfaceImplementers", 2, "description"], + }); }); it("handles objects with only __typename when inline fragment doesn't match", () => { @@ -1130,9 +1136,10 @@ describe("validateFixtureInput", () => { // count is Int! so null should not be allowed expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe( - 'Expected non-nullable type "Int!" not to be null. At ""', - ); + expect(result.errors[0]).toStrictEqual({ + message: 'Expected non-nullable type "Int!" not to be null.', + path: ["data", "items", 0, "count"], + }); }); it("should detect null in non-nullable array", () => { @@ -1157,9 +1164,10 @@ describe("validateFixtureInput", () => { // items is [Item!]! so null should not be allowed expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe( - "Null value found in non-nullable array at items[1]", - ); + expect(result.errors[0]).toStrictEqual({ + message: "Null value found in non-nullable array", + path: ["data", "items", 1], + }); }); it("should detect null in non-nullable object field", () => { @@ -1184,9 +1192,10 @@ describe("validateFixtureInput", () => { // requiredMetadata is Metadata! so null should not be allowed expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe( - "Expected object for requiredMetadata, but got null", - ); + expect(result.errors[0]).toStrictEqual({ + message: "Expected object, but got null", + path: ["data", "requiredMetadata"], + }); }); it("detects missing fields in fixture data", () => { @@ -1220,13 +1229,18 @@ describe("validateFixtureInput", () => { const result = validateFixtureInput(queryAST, schema, fixtureInput); expect(result.errors).toHaveLength(3); - expect(result.errors[0]).toBe("Missing expected fixture data for count"); - expect(result.errors[1]).toBe( - "Missing expected fixture data for details", - ); - expect(result.errors[2]).toBe( - "Missing expected fixture data for metadata", - ); + expect(result.errors[0]).toStrictEqual({ + message: "Missing expected fixture data for count", + path: ["data", "items", 0, "count"], + }); + expect(result.errors[1]).toStrictEqual({ + message: "Missing expected fixture data for details", + path: ["data", "items", 0, "details"], + }); + expect(result.errors[2]).toStrictEqual({ + message: "Missing expected fixture data for metadata", + path: ["data", "metadata"], + }); }); it("detects extra fields not in query", () => { @@ -1255,9 +1269,10 @@ describe("validateFixtureInput", () => { // Should detect that 'count' is not in the query expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe( - 'Extra field "count" found in fixture data not in query', - ); + expect(result.errors[0]).toStrictEqual({ + message: 'Extra field "count" found in fixture data not in query', + path: ["data", "items", 0, "count"], + }); }); it("detects extra fields with multiple aliases for the same field", () => { @@ -1305,12 +1320,14 @@ describe("validateFixtureInput", () => { // Each alias is validated independently, so extra fields in each should be detected expect(result.errors).toHaveLength(2); - expect(result.errors[0]).toBe( - 'Extra field "details" found in fixture data not in query', - ); - expect(result.errors[1]).toBe( - 'Extra field "count" found in fixture data not in query', - ); + expect(result.errors[0]).toStrictEqual({ + message: 'Extra field "details" found in fixture data not in query', + path: ["data", "firstItems", 0, "details"], + }); + expect(result.errors[1]).toStrictEqual({ + message: 'Extra field "count" found in fixture data not in query', + path: ["data", "secondItems", 0, "count"], + }); }); it("detects extra fields at root level", () => { @@ -1339,9 +1356,10 @@ describe("validateFixtureInput", () => { // Should detect the version field since it wasn't selected in the query expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe( - 'Extra field "version" found in fixture data not in query', - ); + expect(result.errors[0]).toStrictEqual({ + message: 'Extra field "version" found in fixture data not in query', + path: ["version"], + }); }); it("detects extra fields with complex nesting, typename aliases, and type discrimination", () => { @@ -1465,33 +1483,44 @@ describe("validateFixtureInput", () => { // Empty object in implementersNoType is valid (single fragment without __typename - union mode) // Errors appear in post-order traversal (deepest to shallowest): expect(result.errors).toHaveLength(9); - expect(result.errors[0]).toBe( - 'Extra field "extraDetailField" found in fixture data not in query', - ); // details (deepest) - expect(result.errors[1]).toBe( - 'Extra field "count" found in fixture data not in query', - ); // Item - expect(result.errors[2]).toBe( - 'Extra field "phone" found in fixture data not in query', - ); // Metadata - expect(result.errors[3]).toBe( - 'Extra field "description" found in fixture data not in query', - ); // InterfaceImplementer1 - expect(result.errors[4]).toBe( - 'Extra field "extraField" found in fixture data not in query', - ); // InterfaceImplementer2 - expect(result.errors[5]).toBe( - 'Extra field "value" found in fixture data not in query', - ); // NestedInnerA cross-contamination - expect(result.errors[6]).toBe( - 'Extra field "email" found in fixture data not in query', - ); // NestedOuterA cross-contamination - expect(result.errors[7]).toBe( - 'Extra field "id" found in fixture data not in query', - ); // NestedOuterB cross-contamination - expect(result.errors[8]).toBe( - 'Extra field "extraRootField" found in fixture data not in query', - ); // root (last) + expect(result.errors[0]).toStrictEqual({ + message: + 'Extra field "extraDetailField" found in fixture data not in query', + path: ["data", "searchResults", 0, "details", "extraDetailField"], + }); // details (deepest) + expect(result.errors[1]).toStrictEqual({ + message: 'Extra field "count" found in fixture data not in query', + path: ["data", "searchResults", 0, "count"], + }); // Item + expect(result.errors[2]).toStrictEqual({ + message: 'Extra field "phone" found in fixture data not in query', + path: ["data", "searchResults", 1, "phone"], + }); // Metadata + expect(result.errors[3]).toStrictEqual({ + message: 'Extra field "description" found in fixture data not in query', + path: ["data", "interfaceImplementers", 0, "description"], + }); // InterfaceImplementer1 + expect(result.errors[4]).toStrictEqual({ + message: 'Extra field "extraField" found in fixture data not in query', + path: ["data", "interfaceImplementers", 1, "extraField"], + }); // InterfaceImplementer2 + expect(result.errors[5]).toStrictEqual({ + message: 'Extra field "value" found in fixture data not in query', + path: ["data", "nested", 0, "inner", 0, "value"], + }); // NestedInnerA cross-contamination + expect(result.errors[6]).toStrictEqual({ + message: 'Extra field "email" found in fixture data not in query', + path: ["data", "nested", 0, "email"], + }); // NestedOuterA cross-contamination + expect(result.errors[7]).toStrictEqual({ + message: 'Extra field "id" found in fixture data not in query', + path: ["data", "nested", 1, "id"], + }); // NestedOuterB cross-contamination + expect(result.errors[8]).toStrictEqual({ + message: + 'Extra field "extraRootField" found in fixture data not in query', + path: ["extraRootField"], + }); // root (last) }); it("detects extra fields in union types with inline fragments", () => { @@ -1532,12 +1561,14 @@ describe("validateFixtureInput", () => { // Should detect extra fields in both union members expect(result.errors).toHaveLength(2); - expect(result.errors[0]).toBe( - 'Extra field "count" found in fixture data not in query', - ); - expect(result.errors[1]).toBe( - 'Extra field "phone" found in fixture data not in query', - ); + expect(result.errors[0]).toStrictEqual({ + message: 'Extra field "count" found in fixture data not in query', + path: ["data", "searchResults", 0, "count"], + }); + expect(result.errors[1]).toStrictEqual({ + message: 'Extra field "phone" found in fixture data not in query', + path: ["data", "searchResults", 1, "phone"], + }); }); it("detects fields from wrong fragment type in unions (cross-contamination)", () => { @@ -1586,18 +1617,22 @@ describe("validateFixtureInput", () => { // Item should NOT have email/phone (those are Metadata fields) // Metadata should NOT have id/count (those are Item fields) expect(result.errors).toHaveLength(4); - expect(result.errors[0]).toBe( - 'Extra field "email" found in fixture data not in query', - ); - expect(result.errors[1]).toBe( - 'Extra field "phone" found in fixture data not in query', - ); - expect(result.errors[2]).toBe( - 'Extra field "id" found in fixture data not in query', - ); - expect(result.errors[3]).toBe( - 'Extra field "count" found in fixture data not in query', - ); + expect(result.errors[0]).toStrictEqual({ + message: 'Extra field "email" found in fixture data not in query', + path: ["data", "searchResults", 0, "email"], + }); + expect(result.errors[1]).toStrictEqual({ + message: 'Extra field "phone" found in fixture data not in query', + path: ["data", "searchResults", 0, "phone"], + }); + expect(result.errors[2]).toStrictEqual({ + message: 'Extra field "id" found in fixture data not in query', + path: ["data", "searchResults", 1, "id"], + }); + expect(result.errors[3]).toStrictEqual({ + message: 'Extra field "count" found in fixture data not in query', + path: ["data", "searchResults", 1, "count"], + }); }); it("detects extra fields in interface fragments with type discrimination", () => { @@ -1655,24 +1690,30 @@ describe("validateFixtureInput", () => { // - description and extraField2 on InterfaceImplementer2 (doesn't implement HasDescription) // - name, description, and extraField3 on InterfaceImplementer3 (doesn't implement HasName or HasDescription) expect(result.errors).toHaveLength(6); - expect(result.errors[0]).toBe( - 'Extra field "extraField1" found in fixture data not in query', - ); - expect(result.errors[1]).toBe( - 'Extra field "description" found in fixture data not in query', - ); - expect(result.errors[2]).toBe( - 'Extra field "extraField2" found in fixture data not in query', - ); - expect(result.errors[3]).toBe( - 'Extra field "name" found in fixture data not in query', - ); - expect(result.errors[4]).toBe( - 'Extra field "description" found in fixture data not in query', - ); - expect(result.errors[5]).toBe( - 'Extra field "extraField3" found in fixture data not in query', - ); + expect(result.errors[0]).toStrictEqual({ + message: 'Extra field "extraField1" found in fixture data not in query', + path: ["data", "interfaceImplementers", 0, "extraField1"], + }); + expect(result.errors[1]).toStrictEqual({ + message: 'Extra field "description" found in fixture data not in query', + path: ["data", "interfaceImplementers", 1, "description"], + }); + expect(result.errors[2]).toStrictEqual({ + message: 'Extra field "extraField2" found in fixture data not in query', + path: ["data", "interfaceImplementers", 1, "extraField2"], + }); + expect(result.errors[3]).toStrictEqual({ + message: 'Extra field "name" found in fixture data not in query', + path: ["data", "interfaceImplementers", 2, "name"], + }); + expect(result.errors[4]).toStrictEqual({ + message: 'Extra field "description" found in fixture data not in query', + path: ["data", "interfaceImplementers", 2, "description"], + }); + expect(result.errors[5]).toStrictEqual({ + message: 'Extra field "extraField3" found in fixture data not in query', + path: ["data", "interfaceImplementers", 2, "extraField3"], + }); }); it("detects extra fields in truly nested inline fragments (fragment within fragment)", () => { @@ -1732,15 +1773,18 @@ describe("validateFixtureInput", () => { // - email on NestedOuterA (outer level) // - id on NestedOuterB (outer level) expect(result.errors).toHaveLength(3); - expect(result.errors[0]).toBe( - 'Extra field "value" found in fixture data not in query', - ); - expect(result.errors[1]).toBe( - 'Extra field "email" found in fixture data not in query', - ); - expect(result.errors[2]).toBe( - 'Extra field "id" found in fixture data not in query', - ); + expect(result.errors[0]).toStrictEqual({ + message: 'Extra field "value" found in fixture data not in query', + path: ["data", "nested", 0, "inner", 0, "value"], + }); + expect(result.errors[1]).toStrictEqual({ + message: 'Extra field "email" found in fixture data not in query', + path: ["data", "nested", 0, "email"], + }); + expect(result.errors[2]).toStrictEqual({ + message: 'Extra field "id" found in fixture data not in query', + path: ["data", "nested", 1, "id"], + }); }); it("detects extra fields in nested inline fragments on concrete union types", () => { @@ -1797,24 +1841,30 @@ describe("validateFixtureInput", () => { // - name and extraField2 on InterfaceImplementer2 (only id queried) // - name and description on InterfaceImplementer3 (only id queried) expect(result.errors).toHaveLength(6); - expect(result.errors[0]).toBe( - 'Extra field "description" found in fixture data not in query', - ); - expect(result.errors[1]).toBe( - 'Extra field "extraField1" found in fixture data not in query', - ); - expect(result.errors[2]).toBe( - 'Extra field "name" found in fixture data not in query', - ); - expect(result.errors[3]).toBe( - 'Extra field "extraField2" found in fixture data not in query', - ); - expect(result.errors[4]).toBe( - 'Extra field "name" found in fixture data not in query', - ); - expect(result.errors[5]).toBe( - 'Extra field "description" found in fixture data not in query', - ); + expect(result.errors[0]).toStrictEqual({ + message: 'Extra field "description" found in fixture data not in query', + path: ["data", "interfaceImplementers", 0, "description"], + }); + expect(result.errors[1]).toStrictEqual({ + message: 'Extra field "extraField1" found in fixture data not in query', + path: ["data", "interfaceImplementers", 0, "extraField1"], + }); + expect(result.errors[2]).toStrictEqual({ + message: 'Extra field "name" found in fixture data not in query', + path: ["data", "interfaceImplementers", 1, "name"], + }); + expect(result.errors[3]).toStrictEqual({ + message: 'Extra field "extraField2" found in fixture data not in query', + path: ["data", "interfaceImplementers", 1, "extraField2"], + }); + expect(result.errors[4]).toStrictEqual({ + message: 'Extra field "name" found in fixture data not in query', + path: ["data", "interfaceImplementers", 2, "name"], + }); + expect(result.errors[5]).toStrictEqual({ + message: 'Extra field "description" found in fixture data not in query', + path: ["data", "interfaceImplementers", 2, "description"], + }); }); it("detects type mismatches (object vs scalar)", () => { @@ -1842,7 +1892,10 @@ describe("validateFixtureInput", () => { const result = validateFixtureInput(queryAST, schema, fixtureInput); expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe("Expected object for data, but got string"); + expect(result.errors[0]).toStrictEqual({ + message: "Expected object, but got string", + path: ["data"], + }); }); it("detects invalid scalar values", () => { @@ -1871,9 +1924,10 @@ describe("validateFixtureInput", () => { const result = validateFixtureInput(queryAST, schema, fixtureInput); expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe( - 'Int cannot represent non-integer value: "not a number" At ""', - ); + expect(result.errors[0]).toStrictEqual({ + message: 'Int cannot represent non-integer value: "not a number"', + path: ["data", "items", 0, "count"], + }); }); it("detects missing required fields at root level", () => { @@ -1903,9 +1957,10 @@ describe("validateFixtureInput", () => { const result = validateFixtureInput(queryAST, schema, fixtureInput); expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe( - "Missing expected fixture data for metadata", - ); + expect(result.errors[0]).toStrictEqual({ + message: "Missing expected fixture data for metadata", + path: ["data", "metadata"], + }); }); it("should detect incorrect array nesting depth", () => { @@ -1935,12 +1990,14 @@ describe("validateFixtureInput", () => { // Should detect that we got objects where we expected arrays expect(result.errors).toHaveLength(2); - expect(result.errors[0]).toBe( - "Expected array at itemMatrix[0], but got object", - ); - expect(result.errors[1]).toBe( - "Expected array at itemMatrix[1], but got object", - ); + expect(result.errors[0]).toStrictEqual({ + message: "Expected array, but got object", + path: ["data", "itemMatrix", 0], + }); + expect(result.errors[1]).toStrictEqual({ + message: "Expected array, but got object", + path: ["data", "itemMatrix", 1], + }); }); it("should detect non-array value where array is expected", () => { @@ -1967,7 +2024,10 @@ describe("validateFixtureInput", () => { // Should detect that we got an object where we expected an array expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe("Expected array for items, but got object"); + expect(result.errors[0]).toStrictEqual({ + message: "Expected array, but got object", + path: ["data", "items"], + }); }); it("detects fields with missing type information", () => { @@ -1998,9 +2058,10 @@ describe("validateFixtureInput", () => { // Should detect missing type information for the invalid field expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe( - "Cannot validate nonExistentField: missing field definition", - ); + expect(result.errors[0]).toStrictEqual({ + message: "Cannot validate nonExistentField: missing field definition", + path: ["data", "items", "nonExistentField"], + }); }); it("detects empty objects in non-union context", () => { @@ -2031,8 +2092,14 @@ describe("validateFixtureInput", () => { // Empty object {} is invalid in non-union context - missing required fields expect(result.errors).toHaveLength(2); - expect(result.errors[0]).toBe("Missing expected fixture data for id"); - expect(result.errors[1]).toBe("Missing expected fixture data for count"); + expect(result.errors[0]).toStrictEqual({ + message: "Missing expected fixture data for id", + path: ["data", "items", 1, "id"], + }); + expect(result.errors[1]).toStrictEqual({ + message: "Missing expected fixture data for count", + path: ["data", "items", 1, "count"], + }); }); it("detects empty objects when inline fragment is on same type as field", () => { @@ -2059,7 +2126,10 @@ describe("validateFixtureInput", () => { // Empty object {} is invalid when inline fragment is on the same type as the field // We're not discriminating between union members, so all fields are required expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe("Missing expected fixture data for price"); + expect(result.errors[0]).toStrictEqual({ + message: "Missing expected fixture data for price", + path: ["data", "purchasable", "price"], + }); }); it("handles multiple inline fragments on same type without typename", () => { @@ -2095,9 +2165,10 @@ describe("validateFixtureInput", () => { // Still errors on missing __typename because fragmentSpreadCount > 1 // However, NO cascading field errors because all fragments select on same type expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe( - "Missing __typename field for abstract type SearchResult", - ); + expect(result.errors[0]).toStrictEqual({ + message: "Missing __typename field for abstract type SearchResult", + path: ["data", "searchResults"], + }); }); it("detects missing fields when __typename is not selected in union with inline fragments", () => { @@ -2138,9 +2209,10 @@ describe("validateFixtureInput", () => { // Without __typename, we can't discriminate which fields are expected for each object // Validator detects missing __typename for abstract type with 2+ fragments and BREAKs early expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe( - "Missing __typename field for abstract type SearchResult", - ); + expect(result.errors[0]).toStrictEqual({ + message: "Missing __typename field for abstract type SearchResult", + path: ["data", "searchResults"], + }); }); }); }); diff --git a/test/methods/validate-test-assets.test.ts b/test/methods/validate-test-assets.test.ts index b67e3ba..b3853c1 100644 --- a/test/methods/validate-test-assets.test.ts +++ b/test/methods/validate-test-assets.test.ts @@ -119,15 +119,18 @@ describe("validateTestAssets", () => { // Input fixture should be invalid due to type mismatch and missing fields expect(result.inputFixture.errors.length).toBe(3); - expect(result.inputFixture.errors[0]).toContain( - 'Int cannot represent non-integer value: "not_a_number"', - ); - expect(result.inputFixture.errors[1]).toBe( - "Missing expected fixture data for details", - ); - expect(result.inputFixture.errors[2]).toBe( - "Missing expected fixture data for metadata", - ); + expect(result.inputFixture.errors[0]).toStrictEqual({ + message: 'Int cannot represent non-integer value: "not_a_number"', + path: ["data", "items", 0, "count"], + }); + expect(result.inputFixture.errors[1]).toStrictEqual({ + message: "Missing expected fixture data for details", + path: ["data", "items", 0, "details"], + }); + expect(result.inputFixture.errors[2]).toStrictEqual({ + message: "Missing expected fixture data for metadata", + path: ["data", "metadata"], + }); }); it("should detect input fixture with invalid fields", async () => { @@ -159,15 +162,19 @@ describe("validateTestAssets", () => { // Input fixture should be invalid due to missing fields and extra field expect(result.inputFixture.errors.length).toBe(3); - expect(result.inputFixture.errors[0]).toBe( - "Missing expected fixture data for details", - ); - expect(result.inputFixture.errors[1]).toBe( - 'Extra field "invalidField" found in fixture data not in query', - ); - expect(result.inputFixture.errors[2]).toBe( - "Missing expected fixture data for metadata", - ); + expect(result.inputFixture.errors[0]).toStrictEqual({ + message: "Missing expected fixture data for details", + path: ["data", "items", 0, "details"], + }); + expect(result.inputFixture.errors[1]).toStrictEqual({ + message: + 'Extra field "invalidField" found in fixture data not in query', + path: ["data", "items", 0, "invalidField"], + }); + expect(result.inputFixture.errors[2]).toStrictEqual({ + message: "Missing expected fixture data for metadata", + path: ["data", "metadata"], + }); expect(result.outputFixture.errors).toHaveLength(0); }); From df053cb45bd2752ad9ecc13cc2315b93741de362 Mon Sep 17 00:00:00 2001 From: Adam Petro Date: Mon, 10 Nov 2025 09:58:47 -0500 Subject: [PATCH 2/2] Add changeset --- .changeset/fifty-schools-behave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fifty-schools-behave.md diff --git a/.changeset/fifty-schools-behave.md b/.changeset/fifty-schools-behave.md new file mode 100644 index 0000000..8feb2dd --- /dev/null +++ b/.changeset/fifty-schools-behave.md @@ -0,0 +1,5 @@ +--- +"@shopify/shopify-function-test-helpers": patch +--- + +Add path to fixture input validation errors