Improve test tooling (#18259)
## Summary Unifies test mocking tooling across Jest and Storybook, replaces handcrafted mock data with auto-generated server-fetched data, and restructures the mock data generation script for maintainability. ### Mock data generation - Split `generate-mock-data.ts` into three focused modules under `scripts/mock-data/`: - `utils.ts` — shared authentication, GraphQL client, and file writer - `generate-metadata.ts` — fetches object metadata from `/metadata` - `generate-record-data.ts` — fetches record data from `/graphql` using metadata-driven dynamic queries - The orchestrator (`generate-mock-data.ts`) authenticates once and passes the token to both generators - Company records are now fetched from the actual server (limited to 10 records) instead of being handcrafted - Generated files are organized under `generated/metadata/objects/` and `generated/data/companies/` ### Unified test utilities - Consolidated Jest and MSW mocking into shared utilities that compose production code (`prefillRecord`, `getRecordNodeFromRecord`, `getRecordConnectionFromRecords`) with mock metadata - Renamed `generateEmptyJestRecordNode` → `generateMockRecordNode` and moved to `testing/utils/` - Extracted `generateMockRecordConnection` into its own file - Removed `sanitizeInputForPrefill` workaround (no longer needed with correctly shaped generated data)
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/* eslint-disable no-console */
|
||||
import { graphqlRequest, writeGeneratedFile } from './utils.js';
|
||||
|
||||
// Apollo Client automatically adds __typename to every object level;
|
||||
// raw fetch does not, so we include it explicitly here.
|
||||
const METADATA_QUERY = `
|
||||
query ObjectMetadataItems {
|
||||
objects(paging: { first: 1000 }) {
|
||||
__typename
|
||||
edges {
|
||||
__typename
|
||||
node {
|
||||
__typename
|
||||
id
|
||||
universalIdentifier
|
||||
nameSingular
|
||||
namePlural
|
||||
labelSingular
|
||||
labelPlural
|
||||
description
|
||||
icon
|
||||
isCustom
|
||||
isRemote
|
||||
isActive
|
||||
isSystem
|
||||
isUIReadOnly
|
||||
createdAt
|
||||
updatedAt
|
||||
labelIdentifierFieldMetadataId
|
||||
imageIdentifierFieldMetadataId
|
||||
applicationId
|
||||
shortcut
|
||||
isLabelSyncedWithName
|
||||
isSearchable
|
||||
duplicateCriteria
|
||||
indexMetadataList {
|
||||
__typename
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
name
|
||||
indexWhereClause
|
||||
indexType
|
||||
isUnique
|
||||
isCustom
|
||||
indexFieldMetadataList {
|
||||
__typename
|
||||
id
|
||||
fieldMetadataId
|
||||
createdAt
|
||||
updatedAt
|
||||
order
|
||||
}
|
||||
}
|
||||
fieldsList {
|
||||
__typename
|
||||
id
|
||||
universalIdentifier
|
||||
type
|
||||
name
|
||||
label
|
||||
description
|
||||
icon
|
||||
isCustom
|
||||
isActive
|
||||
isSystem
|
||||
isUIReadOnly
|
||||
isNullable
|
||||
isUnique
|
||||
createdAt
|
||||
updatedAt
|
||||
defaultValue
|
||||
options
|
||||
settings
|
||||
isLabelSyncedWithName
|
||||
morphId
|
||||
applicationId
|
||||
relation {
|
||||
__typename
|
||||
type
|
||||
sourceObjectMetadata {
|
||||
__typename
|
||||
id
|
||||
nameSingular
|
||||
namePlural
|
||||
}
|
||||
targetObjectMetadata {
|
||||
__typename
|
||||
id
|
||||
nameSingular
|
||||
namePlural
|
||||
}
|
||||
sourceFieldMetadata {
|
||||
__typename
|
||||
id
|
||||
name
|
||||
}
|
||||
targetFieldMetadata {
|
||||
__typename
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
morphRelations {
|
||||
__typename
|
||||
type
|
||||
sourceObjectMetadata {
|
||||
__typename
|
||||
id
|
||||
nameSingular
|
||||
namePlural
|
||||
}
|
||||
targetObjectMetadata {
|
||||
__typename
|
||||
id
|
||||
nameSingular
|
||||
namePlural
|
||||
}
|
||||
sourceFieldMetadata {
|
||||
__typename
|
||||
id
|
||||
name
|
||||
}
|
||||
targetFieldMetadata {
|
||||
__typename
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
__typename
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const generateMetadata = async (token: string) => {
|
||||
console.log('Fetching object metadata from /metadata ...');
|
||||
|
||||
const metadata = await graphqlRequest('/metadata', METADATA_QUERY, token);
|
||||
|
||||
writeGeneratedFile(
|
||||
'metadata/objects/mock-objects-metadata.ts',
|
||||
'mockedStandardObjectMetadataQueryResult',
|
||||
'ObjectMetadataItemsQuery',
|
||||
"import { ObjectMetadataItemsQuery } from '~/generated-metadata/graphql';",
|
||||
metadata,
|
||||
);
|
||||
|
||||
return metadata as {
|
||||
objects: { edges: { node: Record<string, unknown> }[] };
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/* eslint-disable no-console */
|
||||
import { print } from 'graphql';
|
||||
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { generateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromObject';
|
||||
import { generateFindManyRecordsQuery } from '@/object-record/utils/generateFindManyRecordsQuery';
|
||||
|
||||
import { graphqlRequest, writeGeneratedFile } from './utils.js';
|
||||
|
||||
const RECORDS_LIMIT = 10;
|
||||
|
||||
// Production query builders omit __typename on connection/edge wrappers
|
||||
// since Apollo Client injects them automatically. Raw fetch needs them explicit.
|
||||
const addTypenamesToSelections = (query: string): string =>
|
||||
query.replace(/\{(?!\s*__typename\b)/g, '{\n__typename');
|
||||
|
||||
const toObjectMetadataItems = (rawMetadata: {
|
||||
objects: { edges: { node: Record<string, unknown> }[] };
|
||||
}): ObjectMetadataItem[] =>
|
||||
rawMetadata.objects.edges.map((edge) => {
|
||||
const { fieldsList, indexMetadataList, ...rest } = edge.node;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
fields: fieldsList,
|
||||
readableFields: fieldsList,
|
||||
updatableFields: fieldsList,
|
||||
indexMetadatas: ((indexMetadataList as unknown[]) ?? []).map(
|
||||
(index: any) => ({
|
||||
...index,
|
||||
indexFieldMetadatas: index.indexFieldMetadataList ?? [],
|
||||
}),
|
||||
),
|
||||
} as unknown as ObjectMetadataItem;
|
||||
});
|
||||
|
||||
export const generateRecordData = async (
|
||||
token: string,
|
||||
rawMetadata: { objects: { edges: { node: Record<string, unknown> }[] } },
|
||||
) => {
|
||||
const objectMetadataItems = toObjectMetadataItems(rawMetadata);
|
||||
|
||||
const companyObject = objectMetadataItems.find(
|
||||
(item) => item.nameSingular === 'company',
|
||||
);
|
||||
|
||||
if (!companyObject) {
|
||||
throw new Error('Company object metadata not found');
|
||||
}
|
||||
|
||||
const recordGqlFields = generateDepthRecordGqlFieldsFromObject({
|
||||
objectMetadataItems,
|
||||
objectMetadataItem: companyObject,
|
||||
depth: 1,
|
||||
});
|
||||
|
||||
const queryDocument = generateFindManyRecordsQuery({
|
||||
objectMetadataItem: companyObject,
|
||||
objectMetadataItems,
|
||||
recordGqlFields,
|
||||
objectPermissionsByObjectMetadataId: {},
|
||||
});
|
||||
|
||||
const query = addTypenamesToSelections(print(queryDocument));
|
||||
|
||||
console.log(
|
||||
`Fetching ${companyObject.namePlural} (limit: ${RECORDS_LIMIT}) from /graphql ...`,
|
||||
);
|
||||
|
||||
const data = (await graphqlRequest('/graphql', query, token, {
|
||||
limit: RECORDS_LIMIT,
|
||||
})) as Record<string, { edges: { node: Record<string, unknown> }[] }>;
|
||||
|
||||
const records = data[companyObject.namePlural].edges.map((edge) => edge.node);
|
||||
|
||||
console.log(` Got ${records.length} ${companyObject.namePlural}.`);
|
||||
|
||||
writeGeneratedFile(
|
||||
'data/companies/mock-companies-data.ts',
|
||||
'mockedCompanyRecords',
|
||||
'Record<string, unknown>[]',
|
||||
'',
|
||||
records,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
/* eslint-disable no-console */
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
|
||||
const SERVER_BASE_URL =
|
||||
process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000';
|
||||
const AUTH_EMAIL = 'jane.austen@apple.dev';
|
||||
const AUTH_PASSWORD = 'tim@apple.dev';
|
||||
const WORKSPACE_SUBDOMAIN = 'apple';
|
||||
|
||||
const serverUrl = new URL(SERVER_BASE_URL);
|
||||
export const WORKSPACE_ORIGIN = `${serverUrl.protocol}//${WORKSPACE_SUBDOMAIN}.${serverUrl.host}`;
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const GENERATED_DIR = path.resolve(
|
||||
currentDir,
|
||||
'../../src/testing/mock-data/generated',
|
||||
);
|
||||
|
||||
export const graphqlRequest = async (
|
||||
endpoint: string,
|
||||
query: string,
|
||||
token?: string,
|
||||
variables?: Record<string, unknown>,
|
||||
): Promise<unknown> => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Origin: WORKSPACE_ORIGIN,
|
||||
};
|
||||
|
||||
if (token !== undefined) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${SERVER_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
const json = (await response.json()) as {
|
||||
data?: unknown;
|
||||
errors?: { message: string }[];
|
||||
};
|
||||
|
||||
if (
|
||||
json.errors !== undefined &&
|
||||
json.errors !== null &&
|
||||
json.errors.length > 0
|
||||
) {
|
||||
const errorDetails = json.errors.map((error) => error.message).join(', ');
|
||||
throw new Error(`GraphQL error on ${endpoint}: ${errorDetails}`);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
};
|
||||
|
||||
export const authenticate = async (): Promise<string> => {
|
||||
console.log(
|
||||
`Authenticating as ${AUTH_EMAIL} on workspace ${WORKSPACE_SUBDOMAIN}...`,
|
||||
);
|
||||
|
||||
const loginData = (await graphqlRequest(
|
||||
'/metadata',
|
||||
`mutation GetLoginTokenFromCredentials {
|
||||
getLoginTokenFromCredentials(
|
||||
email: "${AUTH_EMAIL}",
|
||||
password: "${AUTH_PASSWORD}",
|
||||
origin: "${WORKSPACE_ORIGIN}"
|
||||
) {
|
||||
loginToken { token }
|
||||
}
|
||||
}`,
|
||||
)) as {
|
||||
getLoginTokenFromCredentials: { loginToken: { token: string } };
|
||||
};
|
||||
|
||||
const loginToken = loginData.getLoginTokenFromCredentials.loginToken.token;
|
||||
|
||||
const authData = (await graphqlRequest(
|
||||
'/metadata',
|
||||
`mutation GetAuthTokensFromLoginToken {
|
||||
getAuthTokensFromLoginToken(
|
||||
loginToken: "${loginToken}",
|
||||
origin: "${WORKSPACE_ORIGIN}"
|
||||
) {
|
||||
tokens {
|
||||
accessOrWorkspaceAgnosticToken { token }
|
||||
}
|
||||
}
|
||||
}`,
|
||||
)) as {
|
||||
getAuthTokensFromLoginToken: {
|
||||
tokens: { accessOrWorkspaceAgnosticToken: { token: string } };
|
||||
};
|
||||
};
|
||||
|
||||
const accessToken =
|
||||
authData.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken
|
||||
.token;
|
||||
|
||||
console.log('Authenticated successfully.');
|
||||
return accessToken;
|
||||
};
|
||||
|
||||
export const writeGeneratedFile = (
|
||||
relativePath: string,
|
||||
exportName: string,
|
||||
typeName: string,
|
||||
typeImport: string,
|
||||
data: unknown,
|
||||
) => {
|
||||
const filePath = path.join(GENERATED_DIR, relativePath);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
|
||||
const content = [
|
||||
'/* eslint-disable */',
|
||||
'// @ts-nocheck',
|
||||
typeImport,
|
||||
'',
|
||||
'// This file was automatically generated — do not edit manually.',
|
||||
'',
|
||||
'// prettier-ignore',
|
||||
`export const ${exportName}: ${typeName} =`,
|
||||
JSON.stringify(data, null, 2) + ';',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
console.log(`Written: ${filePath}`);
|
||||
};
|
||||
Reference in New Issue
Block a user