UNPKG

inertia-sails

Version:
433 lines (401 loc) 12.9 kB
/** * Inertia Testing Helpers for Sails.js * * Provides utilities for integration testing Inertia responses using sails.request(). * Works alongside your existing unit tests (helpers) and E2E tests (Playwright/Cypress). * * @docs https://docs.sailscasts.com/boring-stack/testing * @example * const inertia = require('inertia-sails/test')(sails) * * describe('Users', () => { * it('lists users', async () => { * const page = await inertia.request('GET /users') * page * .assertComponent('Users/Index') * .assertHas('users', 10) * }) * }) */ const { INERTIA, VERSION } = require('./lib/helpers/inertia-headers') /** * @typedef {Object} InertiaPage * @property {string} component - The component name * @property {string} url - The current URL * @property {Record<string, any>} props - The page props * @property {Record<string, any>} [flash] - Flash data * @property {boolean} [preserveFragment] - Whether URL fragments are preserved after redirects * @property {string[]} [mergeProps] - Props to merge * @property {string[]} [deepMergeProps] - Props to deep merge * @property {Record<string, string[]>} [deferredProps] - Deferred props by group * @property {string[]} [rescuedProps] - Deferred props rescued after callback failures */ /** * @typedef {Object} SailsResponse * @property {InertiaPage} body - The response body * @property {number} statusCode - HTTP status code */ /** * @typedef {Object} SailsRequestOptions * @property {string} url - Request URL or verb/path pair * @property {Record<string, any>} [data] - Request data * @property {Record<string, any>} [headers] - Request headers */ /** * @typedef {Object} SailsTestApp * @property {{ inertia?: { version?: any } }} config * @property {(options: SailsRequestOptions, callback: (err: Error|null, response: SailsResponse, body: InertiaPage) => void) => void} request */ /** * InertiaTestResponse - Fluent assertions for Inertia page responses */ class InertiaTestResponse { /** * @param {SailsResponse} response - The Sails response object */ constructor(response) { this.response = response this.page = response.body this.status = response.statusCode } /** * Assert the response has a specific status code * @param {number} expected - Expected status code * @returns {InertiaTestResponse} - For chaining */ assertStatus(expected) { if (this.status !== expected) { throw new Error(`Expected status ${expected}, got ${this.status}`) } return this } /** * Assert the Inertia component matches * @param {string} expected - Expected component name * @returns {InertiaTestResponse} - For chaining */ assertComponent(expected) { if (this.page.component !== expected) { throw new Error( `Expected component "${expected}", got "${this.page.component}"` ) } return this } /** * Assert a prop exists (optionally with specific length for arrays) * @param {string} key - Prop key (supports dot notation: 'user.name') * @param {number} [length] - Expected array length * @returns {InertiaTestResponse} - For chaining */ assertHas(key, length) { const value = this._getNestedValue(this.page.props, key) if (value === undefined) { throw new Error(`Missing prop: ${key}`) } if (length !== undefined) { if (!Array.isArray(value)) { throw new Error(`Prop "${key}" is not an array`) } if (value.length !== length) { throw new Error( `Expected "${key}" to have ${length} items, got ${value.length}` ) } } return this } /** * Assert a prop does not exist * @param {string} key - Prop key * @returns {InertiaTestResponse} - For chaining */ assertMissing(key) { const value = this._getNestedValue(this.page.props, key) if (value !== undefined) { throw new Error(`Prop "${key}" should not exist`) } return this } /** * Assert props match expected values * @param {Record<string, any>} expected - Object with expected key-value pairs * @returns {InertiaTestResponse} - For chaining */ assertProps(expected) { for (const [key, expectedValue] of Object.entries(expected)) { const actualValue = this._getNestedValue(this.page.props, key) if (!this._deepEqual(actualValue, expectedValue)) { throw new Error( `Prop "${key}" expected ${JSON.stringify( expectedValue )}, got ${JSON.stringify(actualValue)}` ) } } return this } /** * Assert a prop value using a callback * @param {string} key - Prop key * @param {(value: any) => void} callback - Callback receiving the value, should throw if invalid * @returns {InertiaTestResponse} - For chaining */ assertProp(key, callback) { const value = this._getNestedValue(this.page.props, key) callback(value) return this } /** * Assert flash data exists * @param {string} key - Flash key * @param {*} [value] - Expected value (optional) * @returns {InertiaTestResponse} - For chaining */ assertFlash(key, value) { if (!this.page.flash || this.page.flash[key] === undefined) { throw new Error(`Missing flash: ${key}`) } if (value !== undefined && this.page.flash[key] !== value) { throw new Error( `Flash "${key}" expected "${value}", got "${this.page.flash[key]}"` ) } return this } /** * Assert flash data does not exist * @param {string} key - Flash key * @returns {InertiaTestResponse} - For chaining */ assertNoFlash(key) { if (this.page.flash && this.page.flash[key] !== undefined) { throw new Error(`Flash "${key}" should not exist`) } return this } /** * Assert the URL matches * @param {string} expected - Expected URL * @returns {InertiaTestResponse} - For chaining */ assertUrl(expected) { if (this.page.url !== expected) { throw new Error(`Expected URL "${expected}", got "${this.page.url}"`) } return this } /** * Assert mergeProps contains specific keys * @param {string[]} keys - Expected merge prop keys * @returns {InertiaTestResponse} - For chaining */ assertMergeProps(keys) { const mergeProps = this.page.mergeProps || [] for (const key of keys) { if (!mergeProps.includes(key)) { throw new Error(`Expected "${key}" in mergeProps`) } } return this } /** * Assert deepMergeProps contains specific keys * @param {string[]} keys - Expected deep merge prop keys * @returns {InertiaTestResponse} - For chaining */ assertDeepMergeProps(keys) { const deepMergeProps = this.page.deepMergeProps || [] for (const key of keys) { if (!deepMergeProps.includes(key)) { throw new Error(`Expected "${key}" in deepMergeProps`) } } return this } /** * Assert deferredProps contains specific keys * @param {string[]} keys - Expected deferred prop keys * @param {string} [group] - Optional group name * @returns {InertiaTestResponse} - For chaining */ assertDeferredProps(keys, group = 'default') { const deferredProps = this.page.deferredProps || {} const groupProps = deferredProps[group] || [] for (const key of keys) { if (!groupProps.includes(key)) { throw new Error(`Expected "${key}" in deferredProps.${group}`) } } return this } /** * Assert rescuedProps contains specific keys * @param {string[]} keys - Expected rescued prop keys * @returns {InertiaTestResponse} - For chaining */ assertRescuedProps(keys) { const rescuedProps = this.page.rescuedProps || [] for (const key of keys) { if (!rescuedProps.includes(key)) { throw new Error(`Expected "${key}" in rescuedProps`) } } return this } /** * Assert preserveFragment metadata is enabled * @returns {InertiaTestResponse} - For chaining */ assertPreserveFragment() { if (this.page.preserveFragment !== true) { throw new Error('Expected preserveFragment to be true') } return this } /** * Get the raw page object for custom assertions * @returns {InertiaPage} - The Inertia page object */ getPage() { return this.page } /** * Get the raw props for custom assertions * @returns {Record<string, any>} - The props object */ getProps() { return this.page.props } /** * Helper to get nested values using dot notation * @private * @param {Record<string, any>} obj - The object to search * @param {string} path - Dot-notation path * @returns {*} - The value at the path */ _getNestedValue(obj, path) { return path.split('.').reduce( /** * @param {any} current * @param {string} key */ (current, key) => { return current && current[key] !== undefined ? current[key] : undefined }, obj ) } /** * Helper for deep equality check * @private * @param {*} a - First value * @param {*} b - Second value * @returns {boolean} - Whether the values are deeply equal */ _deepEqual(a, b) { if (a === b) return true if (typeof a !== typeof b) return false if (typeof a !== 'object' || a === null || b === null) return false const keysA = Object.keys(a) const keysB = Object.keys(b) if (keysA.length !== keysB.length) return false return keysA.every((key) => this._deepEqual(a[key], b[key])) } } /** * Create Inertia testing utilities for a Sails instance * @param {SailsTestApp} sails - The Sails application instance * @returns {Record<string, any>} - Testing utilities */ module.exports = function createInertiaTestUtils(sails) { return { /** * Make an Inertia request and return a test response * @param {string|SailsRequestOptions} urlOrOptions - URL string or request options * @returns {Promise<InertiaTestResponse>} - Test response with assertions * @example * // Simple GET * const page = await inertia.request('GET /users') * * // With options * const page = await inertia.request({ * url: 'POST /users', * data: { name: 'John' }, * headers: { 'Authorization': 'Bearer token' } * }) */ async request(urlOrOptions) { const options = typeof urlOrOptions === 'string' ? { url: urlOrOptions } : urlOrOptions return new Promise((resolve, reject) => { sails.request( { ...options, headers: { [INERTIA]: 'true', [VERSION]: sails.config.inertia?.version || '1', ...options.headers } }, /** * @param {Error|null} err - Error if request failed * @param {SailsResponse} response - The response object * @param {InertiaPage} body - The response body */ (err, response, body) => { // For Inertia, even "errors" (redirects, etc) are valid responses // We want to return the response for assertions if (err && !response) { return reject(err) } // Normalize response const normalizedResponse = response || { body, statusCode: 200 } if (!normalizedResponse.body && body) { normalizedResponse.body = body } resolve(new InertiaTestResponse(normalizedResponse)) } ) }) }, /** * Make a partial reload request * @param {string} url - The URL to request * @param {string} component - The component name for partial reload * @param {string[]} only - Props to reload * @returns {Promise<InertiaTestResponse>} - Test response * @example * const page = await inertia.partialRequest('/users', 'Users/Index', ['users']) */ async partialRequest(url, component, only = []) { return this.request({ url, headers: { 'X-Inertia-Partial-Component': component, 'X-Inertia-Partial-Data': only.join(',') } }) }, /** * Make a request with specific props excluded from partial reload * @param {string} url - The URL to request * @param {string} component - The component name * @param {string[]} except - Props to exclude * @returns {Promise<InertiaTestResponse>} - Test response */ async partialExceptRequest(url, component, except = []) { return this.request({ url, headers: { 'X-Inertia-Partial-Component': component, 'X-Inertia-Partial-Except': except.join(',') } }) }, /** * The InertiaTestResponse class for custom extensions */ InertiaTestResponse } }