@oxyhq/core 15.0.1 → 16.1.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/OxyServices.errors.js +33 -1
- package/dist/cjs/OxyServices.js +2 -1
- package/dist/cjs/index.js +8 -5
- package/dist/cjs/mixins/OxyServices.assets.js +31 -8
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +5 -5
- package/dist/cjs/utils/accountUtils.js +6 -1
- package/dist/cjs/utils/displayNamePolicyRanges.generated.js +28 -3
- package/dist/cjs/utils/validationUtils.js +110 -21
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/OxyServices.errors.js +31 -0
- package/dist/esm/OxyServices.js +2 -2
- package/dist/esm/index.js +2 -2
- package/dist/esm/mixins/OxyServices.assets.js +32 -9
- package/dist/esm/mixins/OxyServices.deviceBoot.js +5 -5
- package/dist/esm/utils/accountUtils.js +6 -1
- package/dist/esm/utils/displayNamePolicyRanges.generated.js +27 -2
- package/dist/esm/utils/validationUtils.js +110 -21
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.d.ts +2 -2
- package/dist/types/OxyServices.errors.d.ts +33 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +17 -5
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +5 -5
- package/dist/types/utils/displayNamePolicyRanges.generated.d.ts +27 -2
- package/dist/types/utils/validationUtils.d.ts +104 -20
- package/package.json +2 -2
- package/src/OxyServices.errors.ts +45 -0
- package/src/OxyServices.ts +2 -2
- package/src/index.ts +3 -1
- package/src/mixins/OxyServices.assets.ts +36 -9
- package/src/mixins/OxyServices.deviceBoot.ts +5 -5
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadata.test.ts +52 -3
- package/src/utils/__tests__/accountUtils.test.ts +22 -0
- package/src/utils/__tests__/coldBoot.test.ts +9 -4
- package/src/utils/__tests__/validationUtils.test.ts +292 -1
- package/src/utils/accountUtils.ts +9 -1
- package/src/utils/displayNamePolicyRanges.generated.ts +31 -2
- package/src/utils/validationUtils.ts +113 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "16.1.0",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
"expo-crypto": "*",
|
|
132
132
|
"expo-secure-store": "*",
|
|
133
133
|
"express": "^4.0.0",
|
|
134
|
-
"express-rate-limit": "^
|
|
134
|
+
"express-rate-limit": "^8.0.0",
|
|
135
135
|
"helmet": "^8.0.0"
|
|
136
136
|
},
|
|
137
137
|
"peerDependenciesMeta": {
|
|
@@ -64,6 +64,51 @@ export class AssetUrlResolutionError extends Error {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Thrown when one or more chunks of `getServiceAssetMetadataByIds` could not be
|
|
69
|
+
* resolved.
|
|
70
|
+
*
|
|
71
|
+
* Exists because for this endpoint a FAILED request and an ABSENT asset produce
|
|
72
|
+
* the same observable result. The server legitimately omits unknown/deleted ids,
|
|
73
|
+
* so callers are documented to map the response by `id` and treat a missing
|
|
74
|
+
* entry as "no such asset" — which means a chunk that 429s, times out or 5xxs
|
|
75
|
+
* reads as authoritative absence unless it is raised.
|
|
76
|
+
*
|
|
77
|
+
* That was not hypothetical: a metadata backfill counted every throttled asset
|
|
78
|
+
* as needing no update and exited 0, and the MTN signed-record builder embedded
|
|
79
|
+
* media with no content hash into records that are immutable once signed. Both
|
|
80
|
+
* paths reported success and wrote nothing.
|
|
81
|
+
*
|
|
82
|
+
* `unresolvedIds` carries every id in a failed chunk — not the subset the server
|
|
83
|
+
* would have omitted anyway, which is unknowable when the request never landed.
|
|
84
|
+
* A caller that wants best-effort passes `{ partial: true }` and never sees this.
|
|
85
|
+
*/
|
|
86
|
+
export class ServiceAssetMetadataError extends Error {
|
|
87
|
+
public readonly code = 'SERVICE_ASSET_METADATA_UNRESOLVED';
|
|
88
|
+
/** Every id belonging to a chunk whose request failed. */
|
|
89
|
+
public readonly unresolvedIds: string[];
|
|
90
|
+
/** HTTP statuses observed across the failed chunks (deduped, ascending). */
|
|
91
|
+
public readonly statuses: number[];
|
|
92
|
+
/**
|
|
93
|
+
* The first underlying transport/API failure. Declared on the class rather
|
|
94
|
+
* than relying on `Error.cause` because this package targets ES2020, where
|
|
95
|
+
* `cause` is not part of the `Error` type.
|
|
96
|
+
*/
|
|
97
|
+
public readonly cause?: unknown;
|
|
98
|
+
|
|
99
|
+
constructor(unresolvedIds: string[], statuses: number[], cause?: unknown) {
|
|
100
|
+
const uniqueStatuses = Array.from(new Set(statuses)).sort((a, b) => a - b);
|
|
101
|
+
const statusSuffix = uniqueStatuses.length > 0 ? ` — status ${uniqueStatuses.join(', ')}` : '';
|
|
102
|
+
super(
|
|
103
|
+
`Could not resolve asset metadata for ${unresolvedIds.length} id(s)${statusSuffix}. Treat this as unknown, not as absent; pass { partial: true } for best-effort.`,
|
|
104
|
+
);
|
|
105
|
+
this.name = 'ServiceAssetMetadataError';
|
|
106
|
+
this.unresolvedIds = unresolvedIds;
|
|
107
|
+
this.statuses = uniqueStatuses;
|
|
108
|
+
this.cause = cause;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
67
112
|
export class OxyAuthenticationTimeoutError extends OxyAuthenticationError {
|
|
68
113
|
constructor(operationName: string, timeoutMs: number) {
|
|
69
114
|
super(
|
package/src/OxyServices.ts
CHANGED
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
* See method JSDoc for more details and options.
|
|
58
58
|
*/
|
|
59
59
|
import { OxyServicesBase, type LinkedHttpClient, type OxyConfig } from './OxyServices.base';
|
|
60
|
-
import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
|
|
60
|
+
import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError } from './OxyServices.errors';
|
|
61
61
|
|
|
62
62
|
// Import mixin composition helper
|
|
63
63
|
import { composeOxyServices } from './mixins';
|
|
@@ -151,7 +151,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
// Re-export error classes for convenience
|
|
154
|
-
export { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError };
|
|
154
|
+
export { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError };
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
157
|
* Default Oxy Cloud URL — used when no `cloudURL` is provided to OxyServices.
|
package/src/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ import './crypto/polyfill';
|
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
24
|
// API client
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
|
-
export { OxyServices, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices';
|
|
26
|
+
export { OxyServices, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError } from './OxyServices';
|
|
27
27
|
export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
|
|
28
28
|
export type { LinkedHttpClient } from './OxyServices.base';
|
|
29
29
|
// Auth-refresh handler surface — consumed by `@oxyhq/services`'s OxyContext to
|
|
@@ -479,6 +479,7 @@ export {
|
|
|
479
479
|
EMAIL_REGEX,
|
|
480
480
|
USERNAME_REGEX,
|
|
481
481
|
PASSWORD_REGEX,
|
|
482
|
+
MAX_DISPLAY_NAME_LENGTH,
|
|
482
483
|
isValidEmail,
|
|
483
484
|
isValidUsername,
|
|
484
485
|
isValidPassword,
|
|
@@ -486,6 +487,7 @@ export {
|
|
|
486
487
|
DISPLAY_NAME_ALLOWED_SCRIPTS,
|
|
487
488
|
DISPLAY_NAME_DISALLOWED_SOURCE,
|
|
488
489
|
DISPLAY_NAME_ORPHANED_MARK_SOURCE,
|
|
490
|
+
DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE,
|
|
489
491
|
isRequiredString,
|
|
490
492
|
isRequiredNumber,
|
|
491
493
|
isRequiredBoolean,
|
|
@@ -2,7 +2,7 @@ import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, A
|
|
|
2
2
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
3
3
|
import { isReactNative } from '@oxyhq/protocol';
|
|
4
4
|
import { logger } from '../logger';
|
|
5
|
-
import { AssetUrlResolutionError } from '../OxyServices.errors';
|
|
5
|
+
import { AssetUrlResolutionError, ServiceAssetMetadataError } from '../OxyServices.errors';
|
|
6
6
|
import { extractErrorStatus } from '../utils/errorUtils';
|
|
7
7
|
import { redactUrlQuery } from '../utils/redactUrl';
|
|
8
8
|
|
|
@@ -353,15 +353,28 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
353
353
|
* throws because no credentials are available. A plain user-session request
|
|
354
354
|
* is rejected by the route's service-auth guard.
|
|
355
355
|
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
356
|
+
* FAILURE IS NOT ABSENCE. The server legitimately omits unknown/deleted ids,
|
|
357
|
+
* so a short result is normal — which means a chunk that FAILED (a 429, a
|
|
358
|
+
* timeout, a 5xx) is indistinguishable from "those assets don't exist" if it
|
|
359
|
+
* simply contributes nothing. This method used to swallow a failed chunk and
|
|
360
|
+
* return the rest, so every caller silently read a throttled request as "no
|
|
361
|
+
* metadata": a backfill counted the asset as needing no update, and the
|
|
362
|
+
* signed-record builder embedded a media item with no hash. Both reported
|
|
363
|
+
* success while writing nothing, and the MTN chain's records are immutable.
|
|
364
|
+
*
|
|
365
|
+
* So a failed chunk THROWS {@link ServiceAssetMetadataError} by default,
|
|
366
|
+
* carrying the ids it could not resolve. A caller that genuinely wants
|
|
367
|
+
* best-effort opts in with `{ partial: true }` and gets the old behaviour
|
|
368
|
+
* explicitly. An empty/whitespace-only input resolves immediately with `[]`
|
|
369
|
+
* and performs no network call.
|
|
360
370
|
*
|
|
361
371
|
* Not cached at the SDK layer: it's a POST keyed on a multi-id body (low hit
|
|
362
372
|
* rate), mirroring the sibling service/POST methods which never cache.
|
|
363
373
|
*/
|
|
364
|
-
async getServiceAssetMetadataByIds(
|
|
374
|
+
async getServiceAssetMetadataByIds(
|
|
375
|
+
ids: string[],
|
|
376
|
+
options: { partial?: boolean } = {},
|
|
377
|
+
): Promise<ServiceAssetMetadata[]> {
|
|
365
378
|
const uniqueIds = Array.from(
|
|
366
379
|
new Set(ids.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)),
|
|
367
380
|
);
|
|
@@ -374,7 +387,12 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
374
387
|
chunks.push(uniqueIds.slice(i, i + SERVICE_ASSET_METADATA_CHUNK_SIZE));
|
|
375
388
|
}
|
|
376
389
|
|
|
377
|
-
//
|
|
390
|
+
// Chunks stay independent so one failure never cancels work already in
|
|
391
|
+
// flight; the failures are collected and re-raised together below.
|
|
392
|
+
const unresolvedIds: string[] = [];
|
|
393
|
+
const statuses: number[] = [];
|
|
394
|
+
let firstError: unknown;
|
|
395
|
+
|
|
378
396
|
const settled = await Promise.all(
|
|
379
397
|
chunks.map(async (chunk): Promise<ServiceAssetMetadata[]> => {
|
|
380
398
|
try {
|
|
@@ -385,17 +403,26 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
385
403
|
);
|
|
386
404
|
return Array.isArray(entries) ? entries : [];
|
|
387
405
|
} catch (error: unknown) {
|
|
388
|
-
|
|
406
|
+
const status = extractErrorStatus(error);
|
|
407
|
+
logger.warn('getServiceAssetMetadataByIds: chunk failed', {
|
|
389
408
|
method: 'getServiceAssetMetadataByIds',
|
|
390
409
|
chunkSize: chunk.length,
|
|
391
|
-
status
|
|
410
|
+
status,
|
|
411
|
+
partial: options.partial === true,
|
|
392
412
|
error: error instanceof Error ? error.message : String(error),
|
|
393
413
|
});
|
|
414
|
+
unresolvedIds.push(...chunk);
|
|
415
|
+
if (typeof status === 'number') statuses.push(status);
|
|
416
|
+
firstError ??= error;
|
|
394
417
|
return [];
|
|
395
418
|
}
|
|
396
419
|
}),
|
|
397
420
|
);
|
|
398
421
|
|
|
422
|
+
if (unresolvedIds.length > 0 && options.partial !== true) {
|
|
423
|
+
throw new ServiceAssetMetadataError(unresolvedIds, statuses, firstError);
|
|
424
|
+
}
|
|
425
|
+
|
|
399
426
|
return settled.flat();
|
|
400
427
|
}
|
|
401
428
|
|
|
@@ -169,11 +169,11 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
169
169
|
*
|
|
170
170
|
* That paragraph is LOAD-BEARING, not belt-and-braces: the route sits above
|
|
171
171
|
* oxy-api's router-wide origin guard (deliberately, so a native client with
|
|
172
|
-
* no `Origin` is not rejected)
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
172
|
+
* no `Origin` is not rejected). oxy-api additionally refuses callers that
|
|
173
|
+
* carry browser context signals (`Origin` or `Sec-Fetch-Site`) with
|
|
174
|
+
* `403 browser_not_allowed` — native HTTP clients send neither. Gate the
|
|
175
|
+
* caller by platform on the client as well; do not widen the 404 degrade to
|
|
176
|
+
* 403, which would also swallow a genuine origin misconfiguration.
|
|
177
177
|
*
|
|
178
178
|
* @returns the provisioned credential, or `null` when the endpoint is absent
|
|
179
179
|
* (404). The API deploy leads the SDK release, so a client on a newer SDK
|
|
@@ -10,11 +10,14 @@
|
|
|
10
10
|
* - the `{ data }` envelope is unwrapped to a bare `ServiceAssetMetadata[]`
|
|
11
11
|
* (mirroring how `makeServiceRequest<T[]>` returns the inner array);
|
|
12
12
|
* - inputs > 100 ids are chunked at 100/request and merged;
|
|
13
|
-
* - a failed chunk
|
|
13
|
+
* - a failed chunk THROWS `ServiceAssetMetadataError` naming its ids, so a
|
|
14
|
+
* throttled request can never be read as "those assets do not exist";
|
|
15
|
+
* - `{ partial: true }` opts back into best-effort explicitly.
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
import type { ServiceAssetMetadata } from '../../models/interfaces';
|
|
17
19
|
import { OxyServices } from '../../OxyServices';
|
|
20
|
+
import { ServiceAssetMetadataError } from '../../OxyServices.errors';
|
|
18
21
|
|
|
19
22
|
const sampleEntry: ServiceAssetMetadata = {
|
|
20
23
|
id: 'asset-1',
|
|
@@ -101,16 +104,62 @@ describe('OxyServices.assets — getServiceAssetMetadataByIds', () => {
|
|
|
101
104
|
expect(result[249]?.id).toBe('asset-249');
|
|
102
105
|
});
|
|
103
106
|
|
|
104
|
-
it('
|
|
107
|
+
it('THROWS when a chunk fails, so a failed request is never read as absence', async () => {
|
|
108
|
+
// The load-bearing assertion. The server legitimately omits unknown/deleted
|
|
109
|
+
// ids, so a short result is normal — which is exactly why a chunk that 429s
|
|
110
|
+
// must not just contribute nothing. Swallowing it made a throttled request
|
|
111
|
+
// indistinguishable from "those assets do not exist".
|
|
105
112
|
const ids = Array.from({ length: 150 }, (_, i) => `asset-${i}`);
|
|
106
113
|
|
|
107
114
|
makeServiceRequestSpy
|
|
108
115
|
.mockResolvedValueOnce([sampleEntry]) // first chunk (100 ids) succeeds
|
|
109
116
|
.mockRejectedValueOnce(new Error('chunk failed')); // second chunk (50 ids) fails
|
|
110
117
|
|
|
111
|
-
|
|
118
|
+
await expect(oxy.getServiceAssetMetadataByIds(ids)).rejects.toThrow(ServiceAssetMetadataError);
|
|
119
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledTimes(2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('names every id of the failed chunk on the error', async () => {
|
|
123
|
+
const ids = Array.from({ length: 150 }, (_, i) => `asset-${i}`);
|
|
124
|
+
|
|
125
|
+
makeServiceRequestSpy
|
|
126
|
+
.mockResolvedValueOnce([sampleEntry])
|
|
127
|
+
.mockRejectedValueOnce(Object.assign(new Error('rate limited'), {
|
|
128
|
+
response: { status: 429 },
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
const error = await oxy.getServiceAssetMetadataByIds(ids).catch((e: unknown) => e);
|
|
132
|
+
|
|
133
|
+
expect(error).toBeInstanceOf(ServiceAssetMetadataError);
|
|
134
|
+
const typed = error as ServiceAssetMetadataError;
|
|
135
|
+
// The whole chunk is unresolved: which of those ids the server would have
|
|
136
|
+
// omitted anyway is unknowable when the request never landed.
|
|
137
|
+
expect(typed.unresolvedIds).toHaveLength(50);
|
|
138
|
+
expect(typed.unresolvedIds).toContain('asset-100');
|
|
139
|
+
expect(typed.unresolvedIds).not.toContain('asset-0');
|
|
140
|
+
expect(typed.statuses).toEqual([429]);
|
|
141
|
+
expect(typed.code).toBe('SERVICE_ASSET_METADATA_UNRESOLVED');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns the resolved entries when the caller opts into { partial: true }', async () => {
|
|
145
|
+
const ids = Array.from({ length: 150 }, (_, i) => `asset-${i}`);
|
|
146
|
+
|
|
147
|
+
makeServiceRequestSpy
|
|
148
|
+
.mockResolvedValueOnce([sampleEntry])
|
|
149
|
+
.mockRejectedValueOnce(new Error('chunk failed'));
|
|
150
|
+
|
|
151
|
+
const result = await oxy.getServiceAssetMetadataByIds(ids, { partial: true });
|
|
112
152
|
|
|
113
153
|
expect(makeServiceRequestSpy).toHaveBeenCalledTimes(2);
|
|
114
154
|
expect(result).toEqual([sampleEntry]);
|
|
115
155
|
});
|
|
156
|
+
|
|
157
|
+
it('does not throw when every chunk succeeds, with or without partial', async () => {
|
|
158
|
+
makeServiceRequestSpy.mockResolvedValue([sampleEntry]);
|
|
159
|
+
|
|
160
|
+
await expect(oxy.getServiceAssetMetadataByIds(['asset-1'])).resolves.toEqual([sampleEntry]);
|
|
161
|
+
await expect(
|
|
162
|
+
oxy.getServiceAssetMetadataByIds(['asset-1'], { partial: true }),
|
|
163
|
+
).resolves.toEqual([sampleEntry]);
|
|
164
|
+
});
|
|
116
165
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
createQuickAccount,
|
|
2
3
|
getAccountDisplayName,
|
|
3
4
|
getAccountFallbackHandle,
|
|
4
5
|
formatPublicKeyHandle,
|
|
@@ -116,6 +117,27 @@ describe('getAccountFallbackHandle', () => {
|
|
|
116
117
|
});
|
|
117
118
|
});
|
|
118
119
|
|
|
120
|
+
describe('createQuickAccount', () => {
|
|
121
|
+
it('prefers API name.displayName over composed first/last', () => {
|
|
122
|
+
const account = createQuickAccount('sess-1', {
|
|
123
|
+
name: { first: 'Nate', last: 'Isern', displayName: 'Nate Isern' },
|
|
124
|
+
username: 'nateus',
|
|
125
|
+
id: 'user-1',
|
|
126
|
+
});
|
|
127
|
+
expect(account.displayName).toBe('Nate Isern');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('falls back to the normalized handle when displayName is absent', () => {
|
|
131
|
+
const account = createQuickAccount('sess-1', {
|
|
132
|
+
name: { first: 'Nate', last: 'Isern' },
|
|
133
|
+
username: 'nateus',
|
|
134
|
+
id: 'user-1',
|
|
135
|
+
});
|
|
136
|
+
expect(account.displayName).toBe('nateus');
|
|
137
|
+
expect(account.displayName).not.toBe('Nate Isern');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
119
141
|
describe('formatPublicKeyHandle', () => {
|
|
120
142
|
it('truncates a long key and strips the 0x prefix before re-adding it', () => {
|
|
121
143
|
expect(formatPublicKeyHandle('0x1234567890abcdef')).toBe('0x12345678…');
|
|
@@ -241,13 +241,17 @@ describe('runColdBoot', () => {
|
|
|
241
241
|
it('hangs forever when a step never settles and no deadline is set', async () => {
|
|
242
242
|
const terminalRan = jest.fn();
|
|
243
243
|
let settled = false;
|
|
244
|
+
let releaseHang: (() => void) | undefined;
|
|
244
245
|
|
|
245
246
|
const outcomePromise = runColdBoot<TestSession>({
|
|
246
247
|
steps: [
|
|
247
248
|
{
|
|
248
249
|
id: 'never-settles',
|
|
249
|
-
// Never resolves or rejects — models a hung async call.
|
|
250
|
-
run: () =>
|
|
250
|
+
// Never resolves or rejects until the test tears down — models a hung async call.
|
|
251
|
+
run: () =>
|
|
252
|
+
new Promise<ColdBootStepResult<TestSession>>((resolve) => {
|
|
253
|
+
releaseHang = () => resolve({ kind: 'skip' });
|
|
254
|
+
}),
|
|
251
255
|
},
|
|
252
256
|
{
|
|
253
257
|
id: 'terminal',
|
|
@@ -268,8 +272,9 @@ describe('runColdBoot', () => {
|
|
|
268
272
|
expect(settled).toBe(false);
|
|
269
273
|
expect(terminalRan).not.toHaveBeenCalled();
|
|
270
274
|
|
|
271
|
-
//
|
|
272
|
-
|
|
275
|
+
// Tear down the intentional hang so Jest workers can exit cleanly.
|
|
276
|
+
releaseHang?.();
|
|
277
|
+
await outcomePromise;
|
|
273
278
|
});
|
|
274
279
|
|
|
275
280
|
/**
|