@ethlete/query 6.0.0-next.5 → 6.0.0-next.51
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/CHANGELOG.md +772 -0
- package/fesm2022/ethlete-query-testing.mjs +441 -38
- package/fesm2022/ethlete-query-testing.mjs.map +1 -1
- package/fesm2022/ethlete-query.mjs +15961 -5330
- package/fesm2022/ethlete-query.mjs.map +1 -1
- package/generators/deprecate-legacy-queries/migration.js +77 -0
- package/generators/deprecate-legacy-queries/schema.json +26 -0
- package/generators/generators.json +15 -0
- package/generators/migrate-query-client-features/migration.js +142 -0
- package/generators/migrate-query-client-features/report.js +136 -0
- package/generators/migrate-query-client-features/schema.json +26 -0
- package/generators/migrate-query-opt-in-features/migration.js +194 -0
- package/generators/migrate-query-opt-in-features/report.js +143 -0
- package/generators/migrate-query-opt-in-features/schema.json +31 -0
- package/generators/migrate-to-query-v3/auth-provider-scaffold.js +133 -0
- package/generators/migrate-to-query-v3/cleanup-migration.js +111 -99
- package/generators/migrate-to-query-v3/http-client-check.js +93 -0
- package/generators/migrate-to-query-v3/legacy-prepare-migration.js +129 -40
- package/generators/migrate-to-query-v3/legacy-query-creator-migration.js +237 -152
- package/generators/migrate-to-query-v3/migration-scope.js +38 -0
- package/generators/migrate-to-query-v3/migration.js +17 -9
- package/generators/migrate-to-query-v3/module-graph.js +153 -0
- package/generators/migrate-to-query-v3/query-client-migration.js +152 -53
- package/generators/migrate-to-query-v3/rename-symbols.js +206 -0
- package/generators/migrate-to-query-v3/schema.json +12 -0
- package/generators/migrate-to-query-v3/shared.js +51 -2
- package/generators/prep-for-query-v3/migration.js +71 -14
- package/migrations.json +44 -0
- package/package.json +29 -13
- package/types/ethlete-query-testing.d.ts +179 -12
- package/types/ethlete-query.d.ts +8935 -3203
|
@@ -4,6 +4,18 @@
|
|
|
4
4
|
"title": "Migrate to Query v3",
|
|
5
5
|
"type": "object",
|
|
6
6
|
"properties": {
|
|
7
|
+
"projects": {
|
|
8
|
+
"type": "array",
|
|
9
|
+
"items": { "type": "string" },
|
|
10
|
+
"description": "Nx projects to migrate. Defaults to every project in the workspace. Use this to adopt v3 one app or library at a time instead of rewriting the whole monorepo in one run - but keep a query client and the creators built from it in the same run, since they are migrated together.",
|
|
11
|
+
"alias": "project"
|
|
12
|
+
},
|
|
13
|
+
"include": {
|
|
14
|
+
"type": "array",
|
|
15
|
+
"items": { "type": "string" },
|
|
16
|
+
"description": "Path prefixes to migrate, relative to the workspace root (e.g. 'libs/queries'). Unioned with `projects`. Defaults to the whole workspace.",
|
|
17
|
+
"alias": "includePath"
|
|
18
|
+
},
|
|
7
19
|
"skipFormat": {
|
|
8
20
|
"type": "boolean",
|
|
9
21
|
"description": "Skip formatting files after migration",
|
|
@@ -17,9 +17,13 @@ export const ensureNamedImports = ({ content, importsNeeded, moduleSpecifier })
|
|
|
17
17
|
const sourceFile = createSourceFile(content);
|
|
18
18
|
let importNode;
|
|
19
19
|
ts.forEachChild(sourceFile, (node) => {
|
|
20
|
-
if (
|
|
20
|
+
if (!importNode &&
|
|
21
|
+
ts.isImportDeclaration(node) &&
|
|
21
22
|
ts.isStringLiteral(node.moduleSpecifier) &&
|
|
22
|
-
node.moduleSpecifier.text === moduleSpecifier
|
|
23
|
+
node.moduleSpecifier.text === moduleSpecifier &&
|
|
24
|
+
!node.importClause?.isTypeOnly &&
|
|
25
|
+
node.importClause?.namedBindings &&
|
|
26
|
+
ts.isNamedImports(node.importClause.namedBindings)) {
|
|
23
27
|
importNode = node;
|
|
24
28
|
}
|
|
25
29
|
});
|
|
@@ -55,6 +59,8 @@ export const ensureNamedImports = ({ content, importsNeeded, moduleSpecifier })
|
|
|
55
59
|
};
|
|
56
60
|
export const ensureImportFromQuery = (content, importsNeeded) => ensureNamedImports({ content, importsNeeded, moduleSpecifier: '@ethlete/query' });
|
|
57
61
|
export const ensureAngularCoreImports = (content, importsNeeded) => ensureNamedImports({ content, importsNeeded, moduleSpecifier: '@angular/core' });
|
|
62
|
+
/** `toProvideFn` / `toInjectFn` - the provider-definition extractors every generated ref pair needs. */
|
|
63
|
+
export const ensureImportFromEthleteCore = (content, importsNeeded) => ensureNamedImports({ content, importsNeeded, moduleSpecifier: '@ethlete/core' });
|
|
58
64
|
export const getVariableStatementEnd = (sourceFile, variableName, initializerName) => {
|
|
59
65
|
let variableStatementEnd;
|
|
60
66
|
const visit = (node) => {
|
|
@@ -94,4 +100,47 @@ export const getLineNumber = (node, sourceFile) => {
|
|
|
94
100
|
return line + 1;
|
|
95
101
|
};
|
|
96
102
|
export const getLineNumberFromPosition = (content, position) => content.slice(0, position).split('\n').length;
|
|
103
|
+
const MIGRATION_GUIDE_URL = 'https://ethlete-sdk-docs.web.app/query/migrating-from-v2';
|
|
104
|
+
/**
|
|
105
|
+
* The tag every `createLegacyQueryCreator(…)` wrapper carries. The wrapper is a migration
|
|
106
|
+
* scaffold, so marking it is what turns "which call sites are still on v2" from a grep into
|
|
107
|
+
* strikethrough in the editor.
|
|
108
|
+
*/
|
|
109
|
+
export const legacyQueryDeprecationTag = (creatorName) => `@deprecated Legacy (v2) query wrapper. Migrate the call sites to ${creatorName ? `\`${creatorName}\`` : 'the query creator it wraps'} and delete this wrapper - see ${MIGRATION_GUIDE_URL}.`;
|
|
110
|
+
/**
|
|
111
|
+
* Adds a tag to `node`'s JSDoc, creating the block when there is none. Returns `null` when the
|
|
112
|
+
* tag is already there, so a caller can tell "nothing to do" from "rewritten".
|
|
113
|
+
*
|
|
114
|
+
* Leading `//` comments stay glued to the node - an `eslint-disable-next-line` only works from
|
|
115
|
+
* the line directly above.
|
|
116
|
+
*/
|
|
117
|
+
export const addJsDocTag = (content, sourceFile, node, tag) => {
|
|
118
|
+
const jsDocs = node.jsDoc ?? [];
|
|
119
|
+
const existing = jsDocs[jsDocs.length - 1];
|
|
120
|
+
const tagName = /^@\w+/.exec(tag)?.[0];
|
|
121
|
+
if (existing && tagName && new RegExp(`${tagName}\\b`).test(existing.getText())) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (existing) {
|
|
125
|
+
const start = existing.getStart(sourceFile);
|
|
126
|
+
const indentation = ' '.repeat(sourceFile.getLineAndCharacterOfPosition(start).character);
|
|
127
|
+
const text = existing.getText();
|
|
128
|
+
const body = text.slice(0, text.lastIndexOf('*/')).replace(/\s+$/, '');
|
|
129
|
+
// A one-line `/** … */` has to become a block before a tag line can be appended to it.
|
|
130
|
+
const opened = body.includes('\n') ? body : `/**\n${indentation} * ${body.slice(3).trim()}`;
|
|
131
|
+
return (content.slice(0, start) +
|
|
132
|
+
`${opened}\n${indentation} *\n${indentation} * ${tag}\n${indentation} */` +
|
|
133
|
+
content.slice(existing.getEnd()));
|
|
134
|
+
}
|
|
135
|
+
let start = node.getStart(sourceFile, false);
|
|
136
|
+
for (const comment of (ts.getLeadingCommentRanges(content, node.getFullStart()) ?? []).reverse()) {
|
|
137
|
+
if (comment.kind !== ts.SyntaxKind.SingleLineCommentTrivia)
|
|
138
|
+
break;
|
|
139
|
+
if (!/^\n[ \t]*$/.test(content.slice(comment.end, start)))
|
|
140
|
+
break;
|
|
141
|
+
start = comment.pos;
|
|
142
|
+
}
|
|
143
|
+
const indentation = ' '.repeat(sourceFile.getLineAndCharacterOfPosition(start).character);
|
|
144
|
+
return (content.slice(0, start) + `/**\n${indentation} * ${tag}\n${indentation} */\n${indentation}` + content.slice(start));
|
|
145
|
+
};
|
|
97
146
|
//# sourceMappingURL=shared.js.map
|
|
@@ -58,7 +58,7 @@ function renameConflictingSymbols(tree) {
|
|
|
58
58
|
// Log statistics
|
|
59
59
|
if (updatedFiles.length > 0) {
|
|
60
60
|
console.log(`\n📊 Renamed symbols in ${updatedFiles.length} files:`);
|
|
61
|
-
const hasRenames = Array.from(symbolUsageCounts.entries()).filter(([
|
|
61
|
+
const hasRenames = Array.from(symbolUsageCounts.entries()).filter(([, count]) => count > 0);
|
|
62
62
|
if (hasRenames.length > 0) {
|
|
63
63
|
console.log('\n Symbol usage counts:');
|
|
64
64
|
hasRenames.forEach(([symbol, count]) => {
|
|
@@ -76,7 +76,18 @@ function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
|
|
|
76
76
|
const replacements = [];
|
|
77
77
|
// Track which symbols are imported from @ethlete/query
|
|
78
78
|
const importedSymbols = new Set();
|
|
79
|
+
const namespaceAliases = collectQueryNamespaceAliases(sourceFile);
|
|
79
80
|
function visit(node) {
|
|
81
|
+
const namespaceMember = getNamespaceMember(node, namespaceAliases)?.member;
|
|
82
|
+
const renamedMember = namespaceMember && (TYPE_RENAMES.get(namespaceMember.text) || FUNCTION_RENAMES.get(namespaceMember.text));
|
|
83
|
+
if (namespaceMember && renamedMember) {
|
|
84
|
+
replacements.push({
|
|
85
|
+
start: namespaceMember.getStart(sourceFile),
|
|
86
|
+
end: namespaceMember.getEnd(),
|
|
87
|
+
replacement: renamedMember,
|
|
88
|
+
oldName: namespaceMember.text,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
80
91
|
// Track imports from @ethlete/query
|
|
81
92
|
if (ts.isImportDeclaration(node)) {
|
|
82
93
|
const moduleSpecifier = node.moduleSpecifier;
|
|
@@ -107,7 +118,9 @@ function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
|
|
|
107
118
|
return;
|
|
108
119
|
}
|
|
109
120
|
// Skip if it's the imported name in an import specifier (we'll handle that separately)
|
|
110
|
-
if (ts.isImportSpecifier(parent) && (parent.propertyName === node || parent.name === node))
|
|
121
|
+
if ((ts.isImportSpecifier(parent) && (parent.propertyName === node || parent.name === node)) ||
|
|
122
|
+
(ts.isPropertyAccessExpression(parent) && parent.name === node) ||
|
|
123
|
+
(ts.isQualifiedName(parent) && parent.right === node)) {
|
|
111
124
|
ts.forEachChild(node, visit);
|
|
112
125
|
return;
|
|
113
126
|
}
|
|
@@ -123,7 +136,7 @@ function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
|
|
|
123
136
|
}
|
|
124
137
|
visit(sourceFile);
|
|
125
138
|
// Update imports in @ethlete/query import declarations
|
|
126
|
-
const importReplacements = updateImports(sourceFile
|
|
139
|
+
const importReplacements = updateImports(sourceFile);
|
|
127
140
|
replacements.push(...importReplacements);
|
|
128
141
|
// Count usages
|
|
129
142
|
replacements.forEach((r) => {
|
|
@@ -138,7 +151,34 @@ function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
|
|
|
138
151
|
}
|
|
139
152
|
return result;
|
|
140
153
|
}
|
|
141
|
-
function
|
|
154
|
+
function collectQueryNamespaceAliases(sourceFile) {
|
|
155
|
+
const aliases = new Set();
|
|
156
|
+
sourceFile.statements.forEach((statement) => {
|
|
157
|
+
if (!ts.isImportDeclaration(statement)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const namedBindings = statement.importClause?.namedBindings;
|
|
161
|
+
if (namedBindings &&
|
|
162
|
+
ts.isNamespaceImport(namedBindings) &&
|
|
163
|
+
ts.isStringLiteral(statement.moduleSpecifier) &&
|
|
164
|
+
statement.moduleSpecifier.text === '@ethlete/query') {
|
|
165
|
+
aliases.add(namedBindings.name.text);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
return aliases;
|
|
169
|
+
}
|
|
170
|
+
function getNamespaceMember(node, namespaceAliases) {
|
|
171
|
+
const [namespace, member] = ts.isPropertyAccessExpression(node)
|
|
172
|
+
? [node.expression, node.name]
|
|
173
|
+
: ts.isQualifiedName(node)
|
|
174
|
+
? [node.left, node.right]
|
|
175
|
+
: [];
|
|
176
|
+
if (!namespace || !member || !ts.isIdentifier(namespace) || !ts.isIdentifier(member)) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
return namespaceAliases.has(namespace.text) ? { namespace, member } : undefined;
|
|
180
|
+
}
|
|
181
|
+
function updateImports(sourceFile) {
|
|
142
182
|
const replacements = [];
|
|
143
183
|
function visit(node) {
|
|
144
184
|
if (ts.isImportDeclaration(node)) {
|
|
@@ -232,9 +272,11 @@ function replaceNamespaceInFile(content, filePath) {
|
|
|
232
272
|
ts.forEachChild(node, collectImport);
|
|
233
273
|
}
|
|
234
274
|
collectImport(sourceFile);
|
|
275
|
+
const namespaceAliases = collectQueryNamespaceAliases(sourceFile);
|
|
276
|
+
const namespaceReplacements = collectNamespacedExperimentalQueryReplacements(sourceFile, namespaceAliases);
|
|
235
277
|
// If no ExperimentalQuery import found, return unchanged
|
|
236
278
|
if (!hasExperimentalQueryImport || !experimentalQueryAlias) {
|
|
237
|
-
return
|
|
279
|
+
return applyReplacements(content, namespaceReplacements, namespaceReplacements.length);
|
|
238
280
|
}
|
|
239
281
|
// Second pass: collect used symbols
|
|
240
282
|
function findUsages(node) {
|
|
@@ -313,17 +355,32 @@ function replaceNamespaceInFile(content, filePath) {
|
|
|
313
355
|
ts.forEachChild(node, createReplacements);
|
|
314
356
|
}
|
|
315
357
|
createReplacements(sourceFile);
|
|
316
|
-
//
|
|
358
|
+
// Subtract 1 for the import replacement
|
|
359
|
+
return applyReplacements(content, [...replacements, ...namespaceReplacements], replacements.length - 1 + namespaceReplacements.length);
|
|
360
|
+
}
|
|
361
|
+
function collectNamespacedExperimentalQueryReplacements(sourceFile, namespaceAliases) {
|
|
362
|
+
const replacements = [];
|
|
363
|
+
if (namespaceAliases.size === 0) {
|
|
364
|
+
return replacements;
|
|
365
|
+
}
|
|
366
|
+
function visit(node) {
|
|
367
|
+
const access = getNamespaceMember(node, namespaceAliases);
|
|
368
|
+
if (access?.member.text === 'ExperimentalQuery') {
|
|
369
|
+
replacements.push({ start: node.getStart(sourceFile), end: node.getEnd(), replacement: access.namespace.text });
|
|
370
|
+
}
|
|
371
|
+
ts.forEachChild(node, visit);
|
|
372
|
+
}
|
|
373
|
+
visit(sourceFile);
|
|
374
|
+
return replacements;
|
|
375
|
+
}
|
|
376
|
+
function applyReplacements(content, replacements, replacementCount) {
|
|
317
377
|
let result = content;
|
|
318
|
-
replacements
|
|
319
|
-
|
|
378
|
+
[...replacements]
|
|
379
|
+
.sort((a, b) => b.start - a.start)
|
|
380
|
+
.forEach(({ start, end, replacement }) => {
|
|
320
381
|
result = result.slice(0, start) + replacement + result.slice(end);
|
|
321
|
-
}
|
|
322
|
-
return {
|
|
323
|
-
content: result,
|
|
324
|
-
modified: replacements.length > 0,
|
|
325
|
-
replacementCount: replacements.length - 1, // Subtract 1 for the import replacement
|
|
326
|
-
};
|
|
382
|
+
});
|
|
383
|
+
return { content: result, modified: replacements.length > 0, replacementCount };
|
|
327
384
|
}
|
|
328
385
|
//#endregion
|
|
329
386
|
//# sourceMappingURL=migration.js.map
|
package/migrations.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"migrations": [
|
|
3
|
+
{
|
|
4
|
+
"name": "to-query-v3",
|
|
5
|
+
"version": "5.42.0",
|
|
6
|
+
"kind": "auto",
|
|
7
|
+
"description": "Move the legacy query system onto the new one through the interop layer.",
|
|
8
|
+
"generator": "@ethlete/query:migrate-to-query-v3",
|
|
9
|
+
"docs": "/query/migrating-from-v2"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "prep-for-query-v3",
|
|
13
|
+
"version": "6.0.0-beta.8",
|
|
14
|
+
"kind": "auto",
|
|
15
|
+
"description": "Prepare the call sites the v3 query system changes, before the move itself.",
|
|
16
|
+
"generator": "@ethlete/query:prep-for-query-v3",
|
|
17
|
+
"docs": "/query/migrating-from-v2"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "query-client-features",
|
|
21
|
+
"version": "6.0.0-next.23",
|
|
22
|
+
"kind": "auto",
|
|
23
|
+
"description": "Move the createQueryClient multiTabSync and persistence options onto opt-in features.",
|
|
24
|
+
"generator": "@ethlete/query:migrate-query-client-features",
|
|
25
|
+
"docs": "/query/features"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "query-opt-in-features",
|
|
29
|
+
"version": "6.0.0-next.23",
|
|
30
|
+
"kind": "auto",
|
|
31
|
+
"description": "Move query clients onto the opt-in error pipeline, and bearer auth onto the opt-in multi-tab sync feature.",
|
|
32
|
+
"generator": "@ethlete/query:migrate-query-opt-in-features",
|
|
33
|
+
"docs": "/query/features"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "deprecate-legacy-queries",
|
|
37
|
+
"version": "6.0.0-next.24",
|
|
38
|
+
"kind": "auto",
|
|
39
|
+
"description": "Mark the remaining createLegacyQueryCreator wrappers as deprecated, so their call sites surface as still on v2.",
|
|
40
|
+
"generator": "@ethlete/query:deprecate-legacy-queries",
|
|
41
|
+
"docs": "/query/legacy"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ethlete/query",
|
|
3
|
-
"version": "6.0.0-next.
|
|
3
|
+
"version": "6.0.0-next.51",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"peerDependencies": {
|
|
7
|
-
"@analogjs/vite-plugin-angular": "2.3
|
|
8
|
-
"@angular/common": "
|
|
9
|
-
"@angular/core": "
|
|
10
|
-
"@angular/forms": "
|
|
11
|
-
"@angular/router": "
|
|
12
|
-
"@ethlete/core": "^5.0.0-
|
|
13
|
-
"@ethlete/types": "^2.0.0-
|
|
14
|
-
"@nx/devkit": "
|
|
15
|
-
"@nx/vite": "22.5.4",
|
|
7
|
+
"@analogjs/vite-plugin-angular": "2.6.3",
|
|
8
|
+
"@angular/common": "22.1.6",
|
|
9
|
+
"@angular/core": "22.1.6",
|
|
10
|
+
"@angular/forms": "22.1.6",
|
|
11
|
+
"@angular/router": "22.1.6",
|
|
12
|
+
"@ethlete/core": "^5.0.0-beta.11",
|
|
13
|
+
"@ethlete/types": "^2.0.0-beta.4",
|
|
14
|
+
"@nx/devkit": "23.1.0",
|
|
16
15
|
"rxjs": "7.8.2",
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
16
|
+
"typescript": "6.0.3",
|
|
17
|
+
"vite": "8.1.5"
|
|
18
|
+
},
|
|
19
|
+
"peerDependenciesMeta": {
|
|
20
|
+
"@analogjs/vite-plugin-angular": {
|
|
21
|
+
"optional": true
|
|
22
|
+
},
|
|
23
|
+
"@nx/devkit": {
|
|
24
|
+
"optional": true
|
|
25
|
+
},
|
|
26
|
+
"typescript": {
|
|
27
|
+
"optional": true
|
|
28
|
+
},
|
|
29
|
+
"vite": {
|
|
30
|
+
"optional": true
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"ethlete": {
|
|
34
|
+
"migrations": "./migrations.json"
|
|
20
35
|
},
|
|
21
36
|
"generators": "./generators/generators.json",
|
|
22
37
|
"module": "fesm2022/ethlete-query.mjs",
|
|
@@ -34,6 +49,7 @@
|
|
|
34
49
|
"default": "./fesm2022/ethlete-query-testing.mjs"
|
|
35
50
|
}
|
|
36
51
|
},
|
|
52
|
+
"type": "module",
|
|
37
53
|
"dependencies": {
|
|
38
54
|
"tslib": "^2.3.0"
|
|
39
55
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { AnyCreateQueryClientResult, QueryClientRef, createGetQuery, createPostQuery, createPutQuery, createPatchQuery, createDeleteQuery, BearerAuthProviderFeatureContext, AnyQueryBuilder, BearerAuthProvider, AuthQueryBuilder, TokenRefreshQueryBuilder, TokenRefreshQueryConfig } from '@ethlete/query';
|
|
1
|
+
import { AnyCreateQueryClientResult, QueryClientRef, createGetQuery, createPostQuery, createPutQuery, createPatchQuery, createDeleteQuery, BearerAuthProviderFeatureContext, AnyQueryBuilder, BearerAuthProvider, AuthQueryBuilder, TokenRefreshQueryBuilder, TokenRefreshQueryConfig, QueryPersistenceAdapter, PersistedQueryEntry, WebSocketClientIo, SocketMessageView } from '@ethlete/query';
|
|
2
2
|
import { HttpClient } from '@angular/common/http';
|
|
3
3
|
import { HttpTestingController } from '@angular/common/http/testing';
|
|
4
4
|
import { Injector } from '@angular/core';
|
|
5
5
|
|
|
6
6
|
type QueryTestSetup = {
|
|
7
|
-
queryClient: NonNullable<ReturnType<AnyCreateQueryClientResult[
|
|
7
|
+
queryClient: NonNullable<ReturnType<AnyCreateQueryClientResult['inject']>>;
|
|
8
8
|
queryClientRef: QueryClientRef;
|
|
9
9
|
httpClient: HttpClient;
|
|
10
10
|
httpTesting: HttpTestingController;
|
|
@@ -15,6 +15,11 @@ type QueryTestSetup = {
|
|
|
15
15
|
createPut: ReturnType<typeof createPutQuery>;
|
|
16
16
|
createPatch: ReturnType<typeof createPatchQuery>;
|
|
17
17
|
createDelete: ReturnType<typeof createDeleteQuery>;
|
|
18
|
+
/**
|
|
19
|
+
* Puts back the `console.warn` / `console.error` that were installed before the first
|
|
20
|
+
* {@link setupQueryTest} call. Idempotent, and safe to call from any of the setups a file created.
|
|
21
|
+
*/
|
|
22
|
+
restoreConsole: () => void;
|
|
18
23
|
};
|
|
19
24
|
type QueryTestSetupConfig = {
|
|
20
25
|
baseUrl?: string;
|
|
@@ -70,18 +75,12 @@ type AuthTestSetupConfig<TLoginArgs extends {
|
|
|
70
75
|
accessToken: string;
|
|
71
76
|
refreshToken: string;
|
|
72
77
|
};
|
|
78
|
+
/** Builds the refresh request from the refresh token. See TokenRefreshQueryConfig for details */
|
|
79
|
+
buildRefreshArgs?: TokenRefreshQueryConfig<TRefreshArgs>['buildArgs'];
|
|
73
80
|
/** Feature builders for additional auth functionality */
|
|
74
81
|
features?: [...TFeatures];
|
|
75
82
|
/** Custom bearer decrypt function for testing */
|
|
76
83
|
bearerDecryptFn?: (token: string) => TBearerData;
|
|
77
|
-
/** Whether to disable multi-tab sync. Default: false */
|
|
78
|
-
multiTabSync?: false | {
|
|
79
|
-
enabled?: boolean;
|
|
80
|
-
channelName?: string;
|
|
81
|
-
syncTokens?: boolean;
|
|
82
|
-
syncLogout?: boolean;
|
|
83
|
-
leaderElection?: boolean;
|
|
84
|
-
};
|
|
85
84
|
/** Refresh strategy configuration. See TokenRefreshQueryConfig for details */
|
|
86
85
|
refreshStrategy?: TokenRefreshQueryConfig<TRefreshArgs>['refreshStrategy'];
|
|
87
86
|
/** Minimum refresh interval in milliseconds */
|
|
@@ -90,6 +89,8 @@ type AuthTestSetupConfig<TLoginArgs extends {
|
|
|
90
89
|
refreshIfExpired?: boolean;
|
|
91
90
|
/** Property name for expiration in JWT */
|
|
92
91
|
expiresInPropertyName?: string;
|
|
92
|
+
/** What to do when the refresh query fails for good. Defaults to the provider's own policy */
|
|
93
|
+
onRefreshFailure?: TokenRefreshQueryConfig<TRefreshArgs>['onRefreshFailure'];
|
|
93
94
|
};
|
|
94
95
|
type AuthTestSetup<TLoginArgs extends {
|
|
95
96
|
body: Record<string, unknown>;
|
|
@@ -154,6 +155,114 @@ declare const setupAuthTest: <TLoginArgs extends {
|
|
|
154
155
|
};
|
|
155
156
|
}, TFeatures extends readonly AnyFeatureBuilder[] = readonly AnyFeatureBuilder[], TBearerData = unknown>(config: AuthTestSetupConfig<TLoginArgs, TRefreshArgs, TFeatures, TBearerData>) => AuthTestSetup<TLoginArgs, TRefreshArgs, TFeatures, TBearerData>;
|
|
156
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Test doubles for the two browser APIs the query client's multi-tab sync is built on:
|
|
160
|
+
* `BroadcastChannel` and the Web Locks API (`navigator.locks`).
|
|
161
|
+
*
|
|
162
|
+
* Both fakes are process-wide and shared between every consumer created while they are installed,
|
|
163
|
+
* which is exactly what makes "two tabs" testable: create two query clients in one test and they
|
|
164
|
+
* talk to each other over the fake bus and contend for the same fake locks, going through the real
|
|
165
|
+
* production code path on both sides.
|
|
166
|
+
*
|
|
167
|
+
* Neither jsdom nor happy-dom can stand in here - jsdom ships a `BroadcastChannel` that never
|
|
168
|
+
* delivers to other instances, and no `navigator.locks` at all.
|
|
169
|
+
*/
|
|
170
|
+
/** Something posted on the fake bus, as seen by a receiver. */
|
|
171
|
+
type FakeBroadcastMessage = {
|
|
172
|
+
/** The channel name it was posted on. */
|
|
173
|
+
channel: string;
|
|
174
|
+
/** The structured-cloned payload. */
|
|
175
|
+
data: unknown;
|
|
176
|
+
};
|
|
177
|
+
type FakeBroadcastChannelHandle = {
|
|
178
|
+
/** Every message posted on the bus, in order, across all channel names. */
|
|
179
|
+
posted: FakeBroadcastMessage[];
|
|
180
|
+
/** Uninstalls the fake and restores whatever `BroadcastChannel` was there before. */
|
|
181
|
+
restore: () => void;
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* Replaces `globalThis.BroadcastChannel` with an in-memory bus.
|
|
185
|
+
*
|
|
186
|
+
* Messages are structured-cloned at post time (so a non-cloneable payload throws exactly like the
|
|
187
|
+
* real API does), delivered on a microtask (never synchronously), and never delivered back to the
|
|
188
|
+
* channel that posted them - all three matching browser behavior. `await Promise.resolve()` in a
|
|
189
|
+
* spec is enough to flush delivery.
|
|
190
|
+
*/
|
|
191
|
+
declare const installFakeBroadcastChannel: () => FakeBroadcastChannelHandle;
|
|
192
|
+
type FakeWebLocksHandle = {
|
|
193
|
+
/** The names currently held, in no particular order. */
|
|
194
|
+
heldNames: () => string[];
|
|
195
|
+
/** The names with at least one request queued behind the holder. */
|
|
196
|
+
pendingNames: () => string[];
|
|
197
|
+
/** Uninstalls the fake and restores whatever `navigator.locks` was there before. */
|
|
198
|
+
restore: () => void;
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* Installs an in-memory `navigator.locks` implementing exclusive locks: one holder per name, the
|
|
202
|
+
* rest queued FIFO, granted on a microtask (never synchronously), released when the callback's
|
|
203
|
+
* promise settles. `signal` is honored for queued requests and - as in the real API - ignored once a
|
|
204
|
+
* request has been granted.
|
|
205
|
+
*
|
|
206
|
+
* `ifAvailable` is a try-lock: a request that cannot be granted straight away runs its callback with
|
|
207
|
+
* `null` instead of queueing, exactly as the real API does. `steal` releases the current holder -
|
|
208
|
+
* rejecting its request with an `AbortError` - and is granted ahead of everything queued.
|
|
209
|
+
*
|
|
210
|
+
* `mode: 'shared'` is not implemented; nothing in the library uses it.
|
|
211
|
+
*/
|
|
212
|
+
declare const installFakeWebLocks: () => FakeWebLocksHandle;
|
|
213
|
+
/**
|
|
214
|
+
* Flushes the microtask queue, which is where both fakes schedule their work - message delivery and
|
|
215
|
+
* lock grants. Awaiting it once is enough for a single hop; a grant that has to wait for another
|
|
216
|
+
* tab's release needs one await per hop.
|
|
217
|
+
*/
|
|
218
|
+
declare const flushMultiTabSync: () => Promise<void>;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* An in-memory stand-in for the query client's persisted response store.
|
|
222
|
+
*
|
|
223
|
+
* jsdom has no IndexedDB at all, so this is how persistence is tested - and because the storage
|
|
224
|
+
* adapter is a documented seam rather than a mock of one, a spec running against this exercises the
|
|
225
|
+
* real engine, repository and request code on both the write and the read side.
|
|
226
|
+
*
|
|
227
|
+
* Everything resolves on a microtask, never synchronously, matching the real thing: `await
|
|
228
|
+
* Promise.resolve()` (or `flushMicrotasks`) is what lets a hydration land in a spec.
|
|
229
|
+
*/
|
|
230
|
+
type FakeQueryPersistenceStoreHandle = {
|
|
231
|
+
/** Pass this as `persistence: { adapter }`. Two clients sharing one handle share one store. */
|
|
232
|
+
adapter: QueryPersistenceAdapter;
|
|
233
|
+
/** Pre-populates the store, as if a previous session had written these entries. */
|
|
234
|
+
seed: (entries: PersistedQueryEntry[]) => void;
|
|
235
|
+
/** Every entry currently held, in insertion order. */
|
|
236
|
+
entries: () => PersistedQueryEntry[];
|
|
237
|
+
/** The entry held under `key`, or `undefined`. */
|
|
238
|
+
entry: (key: string) => PersistedQueryEntry | undefined;
|
|
239
|
+
/** How often each adapter method has been called. */
|
|
240
|
+
calls: () => Record<'loadIndex' | 'read' | 'write' | 'remove' | 'clear', number>;
|
|
241
|
+
/** Makes the next `count` writes reject - a full disk, by default. */
|
|
242
|
+
failNextWrites: (count: number, error?: unknown) => void;
|
|
243
|
+
/** Makes the next `count` removals reject. */
|
|
244
|
+
failNextRemoves: (count: number, error?: unknown) => void;
|
|
245
|
+
/** Makes the next `loadIndex` reject. */
|
|
246
|
+
failNextLoadIndex: (error?: unknown) => void;
|
|
247
|
+
/**
|
|
248
|
+
* Holds every subsequent `read` pending until {@link flushReads} is called, for testing the window
|
|
249
|
+
* between a cold mount and its hydration (a slow disk, or a mount that happened before startup
|
|
250
|
+
* finished).
|
|
251
|
+
*/
|
|
252
|
+
deferReads: () => void;
|
|
253
|
+
/** Settles the reads held by {@link deferReads} and stops deferring. */
|
|
254
|
+
flushReads: () => Promise<void>;
|
|
255
|
+
/**
|
|
256
|
+
* Holds every subsequent `loadIndex` pending until {@link flushLoadIndex} is called, for testing
|
|
257
|
+
* what happens to the store while a client's startup is still reading its index (a real disk takes
|
|
258
|
+
* milliseconds). The snapshot each held load returns is the one it would have read when it started.
|
|
259
|
+
*/
|
|
260
|
+
deferLoadIndex: () => void;
|
|
261
|
+
/** Settles the index loads held by {@link deferLoadIndex} and stops deferring. */
|
|
262
|
+
flushLoadIndex: () => Promise<void>;
|
|
263
|
+
};
|
|
264
|
+
declare const createFakeQueryPersistenceStore: () => FakeQueryPersistenceStoreHandle;
|
|
265
|
+
|
|
157
266
|
declare const expectAndFlush: <T = unknown>(httpTesting: HttpTestingController, url: string, response: T, statusOpts?: {
|
|
158
267
|
status: number;
|
|
159
268
|
statusText: string;
|
|
@@ -163,5 +272,63 @@ declare const expectFlushAndWait: <T = unknown>(httpTesting: HttpTestingControll
|
|
|
163
272
|
statusText: string;
|
|
164
273
|
}) => void;
|
|
165
274
|
|
|
166
|
-
|
|
167
|
-
|
|
275
|
+
/** A scripted stand-in for `socket.io-client`'s `io`, driven from a spec. */
|
|
276
|
+
type WebSocketTestDouble = {
|
|
277
|
+
/** Pass this as `createWebSocketClient({ io })`. */
|
|
278
|
+
io: WebSocketClientIo;
|
|
279
|
+
/** The url and options the client called the factory with, or `null` until it did. */
|
|
280
|
+
connection: () => {
|
|
281
|
+
url: string;
|
|
282
|
+
transports: string[] | undefined;
|
|
283
|
+
} | null;
|
|
284
|
+
/** The `withCredentials` the client asked the factory for, or `null` until it called it. */
|
|
285
|
+
withCredentials: () => boolean | null;
|
|
286
|
+
/** The auth payload of every handshake, oldest first - `null` for one made without `auth`. */
|
|
287
|
+
handshakes: () => (object | null)[];
|
|
288
|
+
/** Every message the client emitted, newest last - room joins and leaves included, buffered ones too. */
|
|
289
|
+
sent: () => {
|
|
290
|
+
event: string;
|
|
291
|
+
data: unknown;
|
|
292
|
+
}[];
|
|
293
|
+
/** Every message that reached the server, newest last. An emit buffered while offline lands here on the next connect. */
|
|
294
|
+
delivered: () => {
|
|
295
|
+
event: string;
|
|
296
|
+
data: unknown;
|
|
297
|
+
}[];
|
|
298
|
+
/** Whether the client asked the socket to connect, and whether it has since disconnected it. */
|
|
299
|
+
state: () => {
|
|
300
|
+
connectRequested: boolean;
|
|
301
|
+
disconnected: boolean;
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* Complete the handshake: flush every buffered emit, then turn the client's `isConnected` true. Pass
|
|
305
|
+
* `recovered: true` for a connection-state-recovered reconnect, in which the server kept the rooms.
|
|
306
|
+
*/
|
|
307
|
+
serverConnect: (options?: {
|
|
308
|
+
recovered?: boolean;
|
|
309
|
+
}) => void;
|
|
310
|
+
/** Drop the connection, so `isConnected` turns false. */
|
|
311
|
+
serverDisconnect: () => void;
|
|
312
|
+
/**
|
|
313
|
+
* Let the ping expire: the socket still reports itself connected, but buffers every emit like socket.io
|
|
314
|
+
* does until it notices and closes. Follow it with {@link serverDisconnect}.
|
|
315
|
+
*/
|
|
316
|
+
serverPingExpire: () => void;
|
|
317
|
+
/** Deliver a message the way the server would. */
|
|
318
|
+
serverSend: (message: SocketMessageView) => void;
|
|
319
|
+
/** Deliver a raw frame, for the malformed-payload path. */
|
|
320
|
+
serverSendRaw: (frame: string) => void;
|
|
321
|
+
};
|
|
322
|
+
/**
|
|
323
|
+
* A socket io test double, for specs around `createWebSocketClient` - which takes its `io` factory as
|
|
324
|
+
* an option precisely so a spec (or an app that never opens a socket) needs no `socket.io-client`.
|
|
325
|
+
*
|
|
326
|
+
* Nothing here connects on its own: the client's own `connect()` only flips
|
|
327
|
+
* {@link WebSocketTestDouble.state}, and it is {@link WebSocketTestDouble.serverConnect} that fires
|
|
328
|
+
* the `connect` listener. That split is what makes "joined a room while disconnected, and it was
|
|
329
|
+
* re-joined after a reconnect" testable.
|
|
330
|
+
*/
|
|
331
|
+
declare const createWebSocketTestDouble: () => WebSocketTestDouble;
|
|
332
|
+
|
|
333
|
+
export { createFakeQueryPersistenceStore, createWebSocketTestDouble, expectAndFlush, expectFlushAndWait, flushMultiTabSync, installFakeBroadcastChannel, installFakeWebLocks, setupAuthTest, setupQueryTest };
|
|
334
|
+
export type { AnyFeatureBuilder, AuthTestSetup, AuthTestSetupConfig, FakeBroadcastChannelHandle, FakeBroadcastMessage, FakeQueryPersistenceStoreHandle, FakeWebLocksHandle, QueryTestSetup, QueryTestSetupConfig, WebSocketTestDouble };
|