inertia-sails 1.3.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -2
- package/index.js +133 -81
- package/lib/handle-bad-request.js +20 -6
- package/lib/helpers/build-page-object.js +117 -12
- package/lib/helpers/inertia-headers.js +4 -1
- package/lib/helpers/is-inertia-partial-request.js +10 -0
- package/lib/helpers/is-inertia-request.js +9 -0
- package/lib/helpers/request-context.js +49 -8
- package/lib/helpers/resolve-asset-version.js +12 -0
- package/lib/helpers/resolve-validation-errors.js +12 -5
- package/lib/location.js +11 -0
- package/lib/middleware/inertia-middleware.js +7 -2
- package/lib/props/always-prop.js +6 -2
- package/lib/props/defer-prop.js +46 -6
- package/lib/props/get-partial-data.js +9 -0
- package/lib/props/merge-prop.js +7 -4
- package/lib/props/merge-targets.js +114 -0
- package/lib/props/mergeable-prop.js +87 -0
- package/lib/props/once-prop.js +9 -1
- package/lib/props/optional-prop.js +12 -4
- package/lib/props/pick-props-to-resolve.js +14 -1
- package/lib/props/resolve-deferred-props.js +17 -1
- package/lib/props/resolve-except-props.js +11 -0
- package/lib/props/resolve-merge-props.js +81 -20
- package/lib/props/resolve-once-props.js +17 -7
- package/lib/props/resolve-only-props.js +10 -3
- package/lib/props/resolve-page-props.js +72 -13
- package/lib/props/resolve-scroll-props.js +17 -3
- package/lib/props/scroll-prop.js +30 -8
- package/lib/render.js +68 -9
- package/lib/responses/server-error.js +23 -6
- package/lib/types.js +68 -0
- package/package.json +3 -2
- package/test.js +53 -12
- package/tests/helpers/build-page-object.test.js +183 -0
- package/tests/helpers/preserve-fragment.test.js +97 -0
- package/tests/props/merge-targets.test.js +74 -0
- package/tests/props/resolve-merge-props.test.js +151 -0
- package/tests/props/resolve-page-props.test.js +70 -0
- package/tests/props/resolve-scroll-props.test.js +51 -0
- package/tests/render.test.js +197 -0
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
const ScrollProp = require('./scroll-prop')
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('../types').InertiaProps} InertiaProps
|
|
5
|
+
*
|
|
6
|
+
* @typedef {Object} ScrollPropsMetadata
|
|
7
|
+
* @property {Record<string, {
|
|
8
|
+
* pageName: string,
|
|
9
|
+
* currentPage: number,
|
|
10
|
+
* previousPage: number|null,
|
|
11
|
+
* nextPage: number|null,
|
|
12
|
+
* reset: boolean
|
|
13
|
+
* }>} [scrollProps]
|
|
14
|
+
*/
|
|
15
|
+
|
|
3
16
|
/**
|
|
4
17
|
* Resolve scroll props metadata for the page response.
|
|
5
18
|
* Extracts ScrollProp instances and builds the scrollProps object
|
|
6
|
-
* expected by Inertia
|
|
19
|
+
* expected by Inertia's <InfiniteScroll> component.
|
|
7
20
|
*
|
|
8
|
-
* @param {
|
|
9
|
-
* @returns {
|
|
21
|
+
* @param {InertiaProps} pageProps - The page props
|
|
22
|
+
* @returns {ScrollPropsMetadata} - Object with scrollProps if any exist
|
|
10
23
|
*
|
|
11
24
|
* @example
|
|
12
25
|
* // Input props with ScrollProp instance:
|
|
@@ -26,6 +39,7 @@ const ScrollProp = require('./scroll-prop')
|
|
|
26
39
|
* }
|
|
27
40
|
*/
|
|
28
41
|
module.exports = function resolveScrollProps(pageProps) {
|
|
42
|
+
/** @type {NonNullable<ScrollPropsMetadata['scrollProps']>} */
|
|
29
43
|
const scrollProps = {}
|
|
30
44
|
|
|
31
45
|
for (const [key, value] of Object.entries(pageProps || {})) {
|
package/lib/props/scroll-prop.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
const MergeProp = require('./merge-prop')
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('../types').PropCallback} PropCallback
|
|
5
|
+
*
|
|
6
|
+
* @typedef {Object} ScrollPropOptions
|
|
7
|
+
* @property {number} [page=0] - Current page index (0-based for Waterline)
|
|
8
|
+
* @property {number} [perPage=10] - Items per page
|
|
9
|
+
* @property {number} [total=0] - Total number of items
|
|
10
|
+
* @property {string} [pageName='page'] - Query parameter name for pagination
|
|
11
|
+
* @property {string} [wrapper='data'] - Key to wrap the data in
|
|
12
|
+
* @property {string|null} [matchOn] - Optional field used to match items when merging
|
|
13
|
+
*/
|
|
14
|
+
|
|
3
15
|
/**
|
|
4
16
|
* ScrollProp - Configures paginated data for infinite scrolling.
|
|
5
17
|
*
|
|
@@ -20,13 +32,8 @@ const MergeProp = require('./merge-prop')
|
|
|
20
32
|
*/
|
|
21
33
|
class ScrollProp extends MergeProp {
|
|
22
34
|
/**
|
|
23
|
-
* @param {
|
|
24
|
-
* @param {
|
|
25
|
-
* @param {number} [options.page=0] - Current page index (0-based for Waterline)
|
|
26
|
-
* @param {number} [options.perPage=10] - Items per page
|
|
27
|
-
* @param {number} [options.total=0] - Total number of items
|
|
28
|
-
* @param {string} [options.pageName='page'] - Query parameter name for pagination
|
|
29
|
-
* @param {string} [options.wrapper='data'] - Key to wrap the data in
|
|
35
|
+
* @param {PropCallback} callback - Callback returning the paginated data array
|
|
36
|
+
* @param {ScrollPropOptions} [options] - Pagination options
|
|
30
37
|
*/
|
|
31
38
|
constructor(callback, options = {}) {
|
|
32
39
|
const {
|
|
@@ -34,7 +41,8 @@ class ScrollProp extends MergeProp {
|
|
|
34
41
|
perPage = 10,
|
|
35
42
|
total = 0,
|
|
36
43
|
pageName = 'page',
|
|
37
|
-
wrapper = 'data'
|
|
44
|
+
wrapper = 'data',
|
|
45
|
+
matchOn = null
|
|
38
46
|
} = options
|
|
39
47
|
|
|
40
48
|
// Calculate pagination metadata
|
|
@@ -45,6 +53,7 @@ class ScrollProp extends MergeProp {
|
|
|
45
53
|
const hasPreviousPage = currentPage > 1
|
|
46
54
|
|
|
47
55
|
// Wrap the callback to return structured data with metadata
|
|
56
|
+
/** @type {() => Promise<any>} */
|
|
48
57
|
const wrappedCallback = async () => {
|
|
49
58
|
const data = typeof callback === 'function' ? await callback() : callback
|
|
50
59
|
|
|
@@ -66,13 +75,26 @@ class ScrollProp extends MergeProp {
|
|
|
66
75
|
|
|
67
76
|
super(wrappedCallback)
|
|
68
77
|
|
|
78
|
+
// InfiniteScroll uses request headers to decide append vs prepend.
|
|
79
|
+
/** @type {import('../types').MergeOperation[]} */
|
|
80
|
+
this.mergeOperations = []
|
|
81
|
+
|
|
69
82
|
// Store metadata for potential access
|
|
83
|
+
/** @type {number} */
|
|
70
84
|
this.page = page
|
|
85
|
+
/** @type {number} */
|
|
71
86
|
this.perPage = perPage
|
|
87
|
+
/** @type {number} */
|
|
72
88
|
this.total = total
|
|
89
|
+
/** @type {string} */
|
|
73
90
|
this.pageName = pageName
|
|
91
|
+
/** @type {string} */
|
|
74
92
|
this.wrapper = wrapper
|
|
93
|
+
/** @type {string|null} */
|
|
94
|
+
this.matchOnPath = matchOn
|
|
95
|
+
/** @type {number} */
|
|
75
96
|
this.totalPages = totalPages
|
|
97
|
+
/** @type {number} */
|
|
76
98
|
this.currentPage = currentPage
|
|
77
99
|
}
|
|
78
100
|
}
|
package/lib/render.js
CHANGED
|
@@ -2,7 +2,60 @@ const { encode } = require('querystring')
|
|
|
2
2
|
const inertiaHeaders = require('./helpers/inertia-headers')
|
|
3
3
|
const buildPageObject = require('./helpers/build-page-object')
|
|
4
4
|
const requestContext = require('./helpers/request-context')
|
|
5
|
+
const resolveAssetVersion = require('./helpers/resolve-asset-version')
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {import('./types').InertiaRequest} InertiaRequest
|
|
9
|
+
* @typedef {import('./types').InertiaResponse} InertiaResponse
|
|
10
|
+
* @typedef {import('./types').InertiaProps} InertiaProps
|
|
11
|
+
*
|
|
12
|
+
* @typedef {Object} RenderData
|
|
13
|
+
* @property {string} page
|
|
14
|
+
* @property {InertiaProps} [props]
|
|
15
|
+
* @property {InertiaProps} [locals]
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {InertiaRequest} req
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function getRequestUrl(req) {
|
|
23
|
+
let url = req.url || req.originalUrl || '/'
|
|
24
|
+
const queryParams = req.query || {}
|
|
25
|
+
|
|
26
|
+
if (req.method === 'GET' && Object.keys(queryParams).length) {
|
|
27
|
+
// Only append query params if the URL doesn't already contain them
|
|
28
|
+
// This prevents duplication when redirecting with query parameters
|
|
29
|
+
if (!url.includes('?')) {
|
|
30
|
+
url += `?${encode(queryParams)}`
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return url
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {InertiaRequest} req
|
|
39
|
+
* @param {any} currentVersion
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
function hasAssetVersionMismatch(req, currentVersion) {
|
|
43
|
+
const requestVersion = req.get(inertiaHeaders.VERSION)
|
|
44
|
+
|
|
45
|
+
if (req.method !== 'GET') return false
|
|
46
|
+
if (!req.get(inertiaHeaders.INERTIA)) return false
|
|
47
|
+
if (requestVersion === undefined || requestVersion === null) return false
|
|
48
|
+
if (currentVersion === undefined || currentVersion === null) return false
|
|
49
|
+
|
|
50
|
+
return String(requestVersion) !== String(currentVersion)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {InertiaRequest} req
|
|
55
|
+
* @param {InertiaResponse} res
|
|
56
|
+
* @param {RenderData} data
|
|
57
|
+
* @returns {Promise<any>}
|
|
58
|
+
*/
|
|
6
59
|
module.exports = async function render(req, res, data) {
|
|
7
60
|
const sails = req._sails
|
|
8
61
|
// Use request-scoped rootView if set, otherwise fall back to config
|
|
@@ -15,20 +68,26 @@ module.exports = async function render(req, res, data) {
|
|
|
15
68
|
...data.locals
|
|
16
69
|
}
|
|
17
70
|
|
|
18
|
-
|
|
71
|
+
const currentVersion = resolveAssetVersion(sails)
|
|
72
|
+
const requestUrl = getRequestUrl(req)
|
|
19
73
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (!page.url.includes('?')) {
|
|
25
|
-
page.url += `?${encode(queryParams)}`
|
|
26
|
-
}
|
|
74
|
+
if (hasAssetVersionMismatch(req, currentVersion)) {
|
|
75
|
+
res.set('Vary', 'X-Inertia')
|
|
76
|
+
res.set(inertiaHeaders.LOCATION, requestUrl)
|
|
77
|
+
return res.status(409).end()
|
|
27
78
|
}
|
|
28
79
|
|
|
80
|
+
let page = await buildPageObject(
|
|
81
|
+
/** @type {any} */ (req),
|
|
82
|
+
data.page,
|
|
83
|
+
data.props
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
page.url = requestUrl
|
|
87
|
+
|
|
29
88
|
if (req.get(inertiaHeaders.INERTIA)) {
|
|
30
89
|
res.set(inertiaHeaders.INERTIA, true)
|
|
31
|
-
res.set('Vary', '
|
|
90
|
+
res.set('Vary', 'X-Inertia')
|
|
32
91
|
return res.json(page)
|
|
33
92
|
} else {
|
|
34
93
|
// Implements full page reload
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('../types').InertiaRequest} InertiaRequest
|
|
3
|
+
* @typedef {import('../types').InertiaResponse} InertiaResponse
|
|
4
|
+
* @typedef {import('../types').ErrorHtmlData} ErrorHtmlData
|
|
5
|
+
* @typedef {{ message?: string, stack?: string, name?: string }} ErrorLike
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
/**
|
|
2
9
|
* Server Error Response for Inertia.js
|
|
3
10
|
*
|
|
@@ -9,9 +16,9 @@
|
|
|
9
16
|
* in a modal overlay during development, allowing developers to see stack traces
|
|
10
17
|
* without losing their page state.
|
|
11
18
|
*
|
|
12
|
-
* @param {
|
|
13
|
-
* @param {
|
|
14
|
-
* @param {
|
|
19
|
+
* @param {InertiaRequest} req - Express/Sails request object
|
|
20
|
+
* @param {InertiaResponse} res - Express/Sails response object
|
|
21
|
+
* @param {ErrorLike} [error] - Optional error data or Error object
|
|
15
22
|
* @returns {*} - Response
|
|
16
23
|
*
|
|
17
24
|
* @example
|
|
@@ -29,7 +36,7 @@
|
|
|
29
36
|
module.exports = function handleServerError(req, res, error) {
|
|
30
37
|
const sails = req._sails
|
|
31
38
|
const statusCode = 500
|
|
32
|
-
const isInertiaRequest = req.header('X-Inertia')
|
|
39
|
+
const isInertiaRequest = req.header?.('X-Inertia')
|
|
33
40
|
const isDevelopment = process.env.NODE_ENV !== 'production'
|
|
34
41
|
|
|
35
42
|
// Log the error
|
|
@@ -49,8 +56,8 @@ module.exports = function handleServerError(req, res, error) {
|
|
|
49
56
|
errorName,
|
|
50
57
|
errorMessage,
|
|
51
58
|
errorStack,
|
|
52
|
-
url: req.url,
|
|
53
|
-
method: req.method
|
|
59
|
+
url: req.url || '/',
|
|
60
|
+
method: req.method || 'GET'
|
|
54
61
|
})
|
|
55
62
|
|
|
56
63
|
res.status(statusCode)
|
|
@@ -76,6 +83,10 @@ module.exports = function handleServerError(req, res, error) {
|
|
|
76
83
|
{
|
|
77
84
|
error: isDevelopment ? error : null
|
|
78
85
|
},
|
|
86
|
+
/**
|
|
87
|
+
* @param {Error|null} err
|
|
88
|
+
* @param {string} html
|
|
89
|
+
*/
|
|
79
90
|
(err, html) => {
|
|
80
91
|
if (err) {
|
|
81
92
|
// If view doesn't exist, send a basic response
|
|
@@ -91,6 +102,8 @@ module.exports = function handleServerError(req, res, error) {
|
|
|
91
102
|
|
|
92
103
|
/**
|
|
93
104
|
* Build an HTML error page for the Inertia modal
|
|
105
|
+
* @param {ErrorHtmlData} data
|
|
106
|
+
* @returns {string}
|
|
94
107
|
*/
|
|
95
108
|
function buildErrorHtml({
|
|
96
109
|
statusCode,
|
|
@@ -270,6 +283,8 @@ function buildErrorHtml({
|
|
|
270
283
|
|
|
271
284
|
/**
|
|
272
285
|
* Format stack trace with syntax highlighting
|
|
286
|
+
* @param {string} stack
|
|
287
|
+
* @returns {string}
|
|
273
288
|
*/
|
|
274
289
|
function formatStackTrace(stack) {
|
|
275
290
|
return stack
|
|
@@ -294,6 +309,8 @@ function formatStackTrace(stack) {
|
|
|
294
309
|
|
|
295
310
|
/**
|
|
296
311
|
* Escape HTML special characters
|
|
312
|
+
* @param {any} str
|
|
313
|
+
* @returns {string}
|
|
297
314
|
*/
|
|
298
315
|
function escapeHtml(str) {
|
|
299
316
|
if (!str) return ''
|
package/lib/types.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JSDoc typedefs for inertia-sails.
|
|
3
|
+
*
|
|
4
|
+
* These keep editor type-checking useful without turning the package into
|
|
5
|
+
* TypeScript. The shapes intentionally stay permissive because Sails request,
|
|
6
|
+
* response, and hook objects are extended by applications and other hooks.
|
|
7
|
+
*
|
|
8
|
+
* @typedef {Record<string, any>} InertiaProps
|
|
9
|
+
*
|
|
10
|
+
* @typedef {Object} SailsLike
|
|
11
|
+
* @property {Record<string, any>} config
|
|
12
|
+
* @property {{ info?: (...args: any[]) => void, warn?: (...args: any[]) => void, error?: (...args: any[]) => void }} [log]
|
|
13
|
+
* @property {Record<string, any>} [inertia]
|
|
14
|
+
* @property {{ bind?: (...args: any[]) => any }} [router]
|
|
15
|
+
* @property {(event: string, callback: (...args: any[]) => any) => any} [on]
|
|
16
|
+
*
|
|
17
|
+
* @typedef {Object} InertiaRequest
|
|
18
|
+
* @property {SailsLike} _sails
|
|
19
|
+
* @property {string} [method]
|
|
20
|
+
* @property {string} [url]
|
|
21
|
+
* @property {string} [originalUrl]
|
|
22
|
+
* @property {Record<string, any>} [query]
|
|
23
|
+
* @property {Record<string, any>} [headers]
|
|
24
|
+
* @property {Record<string, any>} [session]
|
|
25
|
+
* @property {boolean} [isSocket]
|
|
26
|
+
* @property {(name: string) => any} get
|
|
27
|
+
* @property {(name: string) => any} [header]
|
|
28
|
+
* @property {(key: string, value?: any) => any[]} [flash]
|
|
29
|
+
*
|
|
30
|
+
* @typedef {Object} InertiaResponse
|
|
31
|
+
* @property {(name: string, value: any) => InertiaResponse} [set]
|
|
32
|
+
* @property {(code: number) => InertiaResponse} [status]
|
|
33
|
+
* @property {(code: number) => any} [sendStatus]
|
|
34
|
+
* @property {(body?: any) => any} [send]
|
|
35
|
+
* @property {(body: any) => any} [json]
|
|
36
|
+
* @property {(...args: any[]) => any} [redirect]
|
|
37
|
+
* @property {(view: string, data?: any, callback?: (err: Error | null, html: string) => any) => any} [view]
|
|
38
|
+
* @property {(type: string) => InertiaResponse} [type]
|
|
39
|
+
* @property {() => any} [end]
|
|
40
|
+
*
|
|
41
|
+
* @typedef {() => any | Promise<any>} PropCallback
|
|
42
|
+
*
|
|
43
|
+
* @typedef {Object} MergeOperation
|
|
44
|
+
* @property {'append'|'prepend'|string} direction
|
|
45
|
+
* @property {string|null} path
|
|
46
|
+
* @property {string|null} matchOn
|
|
47
|
+
* @property {boolean} [isDefault]
|
|
48
|
+
*
|
|
49
|
+
* @typedef {Object} MergeOptions
|
|
50
|
+
* @property {string|Record<string, string|null>} [matchOn]
|
|
51
|
+
*
|
|
52
|
+
* @typedef {Object} ResolvedPageProps
|
|
53
|
+
* @property {InertiaProps} props
|
|
54
|
+
* @property {string[]} rescuedProps
|
|
55
|
+
*
|
|
56
|
+
* @typedef {Object} BadRequestData
|
|
57
|
+
* @property {Array<string|Record<string, string>>} [problems]
|
|
58
|
+
*
|
|
59
|
+
* @typedef {Object} ErrorHtmlData
|
|
60
|
+
* @property {number} statusCode
|
|
61
|
+
* @property {string} errorName
|
|
62
|
+
* @property {string} errorMessage
|
|
63
|
+
* @property {string} errorStack
|
|
64
|
+
* @property {string} url
|
|
65
|
+
* @property {string} method
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
module.exports = {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "inertia-sails",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "The Sails adapter for Inertia.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"sails": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"hookName": "inertia"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"
|
|
11
|
+
"check:js": "tsc --allowJs --checkJs --noEmit --skipLibCheck --target ES2022 --module commonjs --noImplicitAny index.js test.js lib/**/*.js tests/**/*.js",
|
|
12
|
+
"test": "npm run check:js && node --test tests/*.test.js tests/**/*.test.js"
|
|
12
13
|
},
|
|
13
14
|
"repository": {
|
|
14
15
|
"type": "git",
|
package/test.js
CHANGED
|
@@ -24,11 +24,13 @@ const { INERTIA, VERSION } = require('./lib/helpers/inertia-headers')
|
|
|
24
24
|
* @typedef {Object} InertiaPage
|
|
25
25
|
* @property {string} component - The component name
|
|
26
26
|
* @property {string} url - The current URL
|
|
27
|
-
* @property {
|
|
28
|
-
* @property {
|
|
27
|
+
* @property {Record<string, any>} props - The page props
|
|
28
|
+
* @property {Record<string, any>} [flash] - Flash data
|
|
29
|
+
* @property {boolean} [preserveFragment] - Whether URL fragments are preserved after redirects
|
|
29
30
|
* @property {string[]} [mergeProps] - Props to merge
|
|
30
31
|
* @property {string[]} [deepMergeProps] - Props to deep merge
|
|
31
|
-
* @property {
|
|
32
|
+
* @property {Record<string, string[]>} [deferredProps] - Deferred props by group
|
|
33
|
+
* @property {string[]} [rescuedProps] - Deferred props rescued after callback failures
|
|
32
34
|
*/
|
|
33
35
|
|
|
34
36
|
/**
|
|
@@ -37,6 +39,19 @@ const { INERTIA, VERSION } = require('./lib/helpers/inertia-headers')
|
|
|
37
39
|
* @property {number} statusCode - HTTP status code
|
|
38
40
|
*/
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} SailsRequestOptions
|
|
44
|
+
* @property {string} url - Request URL or verb/path pair
|
|
45
|
+
* @property {Record<string, any>} [data] - Request data
|
|
46
|
+
* @property {Record<string, any>} [headers] - Request headers
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {Object} SailsTestApp
|
|
51
|
+
* @property {{ inertia?: { version?: any } }} config
|
|
52
|
+
* @property {(options: SailsRequestOptions, callback: (err: Error|null, response: SailsResponse, body: InertiaPage) => void) => void} request
|
|
53
|
+
*/
|
|
54
|
+
|
|
40
55
|
/**
|
|
41
56
|
* InertiaTestResponse - Fluent assertions for Inertia page responses
|
|
42
57
|
*/
|
|
@@ -115,7 +130,7 @@ class InertiaTestResponse {
|
|
|
115
130
|
|
|
116
131
|
/**
|
|
117
132
|
* Assert props match expected values
|
|
118
|
-
* @param {
|
|
133
|
+
* @param {Record<string, any>} expected - Object with expected key-value pairs
|
|
119
134
|
* @returns {InertiaTestResponse} - For chaining
|
|
120
135
|
*/
|
|
121
136
|
assertProps(expected) {
|
|
@@ -135,7 +150,7 @@ class InertiaTestResponse {
|
|
|
135
150
|
/**
|
|
136
151
|
* Assert a prop value using a callback
|
|
137
152
|
* @param {string} key - Prop key
|
|
138
|
-
* @param {
|
|
153
|
+
* @param {(value: any) => void} callback - Callback receiving the value, should throw if invalid
|
|
139
154
|
* @returns {InertiaTestResponse} - For chaining
|
|
140
155
|
*/
|
|
141
156
|
assertProp(key, callback) {
|
|
@@ -233,9 +248,35 @@ class InertiaTestResponse {
|
|
|
233
248
|
return this
|
|
234
249
|
}
|
|
235
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Assert rescuedProps contains specific keys
|
|
253
|
+
* @param {string[]} keys - Expected rescued prop keys
|
|
254
|
+
* @returns {InertiaTestResponse} - For chaining
|
|
255
|
+
*/
|
|
256
|
+
assertRescuedProps(keys) {
|
|
257
|
+
const rescuedProps = this.page.rescuedProps || []
|
|
258
|
+
for (const key of keys) {
|
|
259
|
+
if (!rescuedProps.includes(key)) {
|
|
260
|
+
throw new Error(`Expected "${key}" in rescuedProps`)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return this
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Assert preserveFragment metadata is enabled
|
|
268
|
+
* @returns {InertiaTestResponse} - For chaining
|
|
269
|
+
*/
|
|
270
|
+
assertPreserveFragment() {
|
|
271
|
+
if (this.page.preserveFragment !== true) {
|
|
272
|
+
throw new Error('Expected preserveFragment to be true')
|
|
273
|
+
}
|
|
274
|
+
return this
|
|
275
|
+
}
|
|
276
|
+
|
|
236
277
|
/**
|
|
237
278
|
* Get the raw page object for custom assertions
|
|
238
|
-
* @returns {
|
|
279
|
+
* @returns {InertiaPage} - The Inertia page object
|
|
239
280
|
*/
|
|
240
281
|
getPage() {
|
|
241
282
|
return this.page
|
|
@@ -243,7 +284,7 @@ class InertiaTestResponse {
|
|
|
243
284
|
|
|
244
285
|
/**
|
|
245
286
|
* Get the raw props for custom assertions
|
|
246
|
-
* @returns {
|
|
287
|
+
* @returns {Record<string, any>} - The props object
|
|
247
288
|
*/
|
|
248
289
|
getProps() {
|
|
249
290
|
return this.page.props
|
|
@@ -252,14 +293,14 @@ class InertiaTestResponse {
|
|
|
252
293
|
/**
|
|
253
294
|
* Helper to get nested values using dot notation
|
|
254
295
|
* @private
|
|
255
|
-
* @param {
|
|
296
|
+
* @param {Record<string, any>} obj - The object to search
|
|
256
297
|
* @param {string} path - Dot-notation path
|
|
257
298
|
* @returns {*} - The value at the path
|
|
258
299
|
*/
|
|
259
300
|
_getNestedValue(obj, path) {
|
|
260
301
|
return path.split('.').reduce(
|
|
261
302
|
/**
|
|
262
|
-
* @param {
|
|
303
|
+
* @param {any} current
|
|
263
304
|
* @param {string} key
|
|
264
305
|
*/
|
|
265
306
|
(current, key) => {
|
|
@@ -289,14 +330,14 @@ class InertiaTestResponse {
|
|
|
289
330
|
|
|
290
331
|
/**
|
|
291
332
|
* Create Inertia testing utilities for a Sails instance
|
|
292
|
-
* @param {
|
|
293
|
-
* @returns {
|
|
333
|
+
* @param {SailsTestApp} sails - The Sails application instance
|
|
334
|
+
* @returns {Record<string, any>} - Testing utilities
|
|
294
335
|
*/
|
|
295
336
|
module.exports = function createInertiaTestUtils(sails) {
|
|
296
337
|
return {
|
|
297
338
|
/**
|
|
298
339
|
* Make an Inertia request and return a test response
|
|
299
|
-
* @param {string|
|
|
340
|
+
* @param {string|SailsRequestOptions} urlOrOptions - URL string or request options
|
|
300
341
|
* @returns {Promise<InertiaTestResponse>} - Test response with assertions
|
|
301
342
|
* @example
|
|
302
343
|
* // Simple GET
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
const { describe, it } = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const buildPageObject = require('../../lib/helpers/build-page-object')
|
|
4
|
+
const DeferProp = require('../../lib/props/defer-prop')
|
|
5
|
+
const {
|
|
6
|
+
INERTIA,
|
|
7
|
+
PARTIAL_COMPONENT,
|
|
8
|
+
PARTIAL_DATA
|
|
9
|
+
} = require('../../lib/helpers/inertia-headers')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {Record<string, any>} object
|
|
13
|
+
* @param {string} key
|
|
14
|
+
* @returns {boolean}
|
|
15
|
+
*/
|
|
16
|
+
function hasOwn(object, key) {
|
|
17
|
+
return Object.prototype.hasOwnProperty.call(object, key)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createRequest({
|
|
21
|
+
sharedProps = {},
|
|
22
|
+
pageProps = {},
|
|
23
|
+
clearHistory = false,
|
|
24
|
+
encryptHistory = false,
|
|
25
|
+
preserveFragment = false,
|
|
26
|
+
flash = {},
|
|
27
|
+
headers = {},
|
|
28
|
+
version = 'test-version',
|
|
29
|
+
url = '/dashboard'
|
|
30
|
+
} = {}) {
|
|
31
|
+
const normalizedHeaders = Object.fromEntries(
|
|
32
|
+
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
url,
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} header
|
|
39
|
+
* @returns {any}
|
|
40
|
+
*/
|
|
41
|
+
get(header) {
|
|
42
|
+
return normalizedHeaders[header.toLowerCase()]
|
|
43
|
+
},
|
|
44
|
+
_sails: {
|
|
45
|
+
config: {
|
|
46
|
+
inertia: {
|
|
47
|
+
version
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
inertia: {
|
|
51
|
+
getShared() {
|
|
52
|
+
return sharedProps
|
|
53
|
+
},
|
|
54
|
+
shouldClearHistory() {
|
|
55
|
+
return clearHistory
|
|
56
|
+
},
|
|
57
|
+
shouldEncryptHistory() {
|
|
58
|
+
return encryptHistory
|
|
59
|
+
},
|
|
60
|
+
consumePreserveFragment() {
|
|
61
|
+
return preserveFragment
|
|
62
|
+
},
|
|
63
|
+
consumeFlash() {
|
|
64
|
+
return flash
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
pageProps
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('buildPageObject', function () {
|
|
73
|
+
it('omits false history flags from the page object', async function () {
|
|
74
|
+
const req = createRequest()
|
|
75
|
+
const page = await buildPageObject(req, 'dashboard/index', {})
|
|
76
|
+
|
|
77
|
+
assert.equal(hasOwn(page, 'clearHistory'), false)
|
|
78
|
+
assert.equal(hasOwn(page, 'encryptHistory'), false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('includes true history flags in the page object', async function () {
|
|
82
|
+
const req = createRequest({
|
|
83
|
+
clearHistory: true,
|
|
84
|
+
encryptHistory: true
|
|
85
|
+
})
|
|
86
|
+
const page = await buildPageObject(req, 'dashboard/index', {})
|
|
87
|
+
|
|
88
|
+
assert.equal(page.clearHistory, true)
|
|
89
|
+
assert.equal(page.encryptHistory, true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('omits false preserveFragment from the page object', async function () {
|
|
93
|
+
const req = createRequest()
|
|
94
|
+
const page = await buildPageObject(req, 'dashboard/index', {})
|
|
95
|
+
|
|
96
|
+
assert.equal(hasOwn(page, 'preserveFragment'), false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('includes true preserveFragment in the page object', async function () {
|
|
100
|
+
const req = createRequest({
|
|
101
|
+
preserveFragment: true
|
|
102
|
+
})
|
|
103
|
+
const page = await buildPageObject(req, 'dashboard/index', {})
|
|
104
|
+
|
|
105
|
+
assert.equal(page.preserveFragment, true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('adds sharedProps metadata for shared prop keys', async function () {
|
|
109
|
+
const req = createRequest({
|
|
110
|
+
sharedProps: {
|
|
111
|
+
auth: { user: { id: 1 } },
|
|
112
|
+
app: { name: 'Boring Stack' }
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
const page = await buildPageObject(req, 'dashboard/index', {
|
|
116
|
+
stats: { users: 10 }
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
assert.deepEqual(page.sharedProps, ['auth', 'app'])
|
|
120
|
+
assert.deepEqual(page.props.auth, { user: { id: 1 } })
|
|
121
|
+
assert.deepEqual(page.props.stats, { users: 10 })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('keeps sharedProps metadata when page props override a shared key', async function () {
|
|
125
|
+
const req = createRequest({
|
|
126
|
+
sharedProps: {
|
|
127
|
+
auth: { user: { id: 1 } }
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
const page = await buildPageObject(req, 'dashboard/index', {
|
|
131
|
+
auth: { user: { id: 2 } }
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
assert.deepEqual(page.sharedProps, ['auth'])
|
|
135
|
+
assert.deepEqual(page.props.auth, { user: { id: 2 } })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('omits sharedProps when no shared props are present', async function () {
|
|
139
|
+
const req = createRequest()
|
|
140
|
+
const page = await buildPageObject(req, 'dashboard/index', {})
|
|
141
|
+
|
|
142
|
+
assert.equal(hasOwn(page, 'sharedProps'), false)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('includes rescuedProps when a rescuable deferred prop fails', async function () {
|
|
146
|
+
const req = createRequest({
|
|
147
|
+
headers: {
|
|
148
|
+
[INERTIA]: 'true',
|
|
149
|
+
[PARTIAL_COMPONENT]: 'dashboard/index',
|
|
150
|
+
[PARTIAL_DATA]: 'analytics'
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
const page = await buildPageObject(req, 'dashboard/index', {
|
|
154
|
+
analytics: new DeferProp(async () => {
|
|
155
|
+
throw new Error('Analytics service unavailable')
|
|
156
|
+
}).rescue()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
assert.deepEqual(page.props, {})
|
|
160
|
+
assert.deepEqual(page.rescuedProps, ['analytics'])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('omits merge metadata for rescued deferred props', async function () {
|
|
164
|
+
const req = createRequest({
|
|
165
|
+
headers: {
|
|
166
|
+
[INERTIA]: 'true',
|
|
167
|
+
[PARTIAL_COMPONENT]: 'dashboard/index',
|
|
168
|
+
[PARTIAL_DATA]: 'analytics'
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
const analytics = new DeferProp(async () => {
|
|
172
|
+
throw new Error('Analytics service unavailable')
|
|
173
|
+
})
|
|
174
|
+
analytics.append('data', 'id')
|
|
175
|
+
analytics.rescue()
|
|
176
|
+
|
|
177
|
+
const page = await buildPageObject(req, 'dashboard/index', { analytics })
|
|
178
|
+
|
|
179
|
+
assert.deepEqual(page.rescuedProps, ['analytics'])
|
|
180
|
+
assert.equal(hasOwn(page, 'mergeProps'), false)
|
|
181
|
+
assert.equal(hasOwn(page, 'matchPropsOn'), false)
|
|
182
|
+
})
|
|
183
|
+
})
|