@solidxai/core 0.1.15 → 0.1.16-beta.1
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 +1293 -0
- package/dist/commands/run-tests.command.d.ts +3 -1
- package/dist/commands/run-tests.command.d.ts.map +1 -1
- package/dist/commands/run-tests.command.js +6 -2
- package/dist/commands/run-tests.command.js.map +1 -1
- package/dist/controllers/test-run.controller.d.ts +12 -0
- package/dist/controllers/test-run.controller.d.ts.map +1 -0
- package/dist/controllers/test-run.controller.js +54 -0
- package/dist/controllers/test-run.controller.js.map +1 -0
- package/dist/dtos/test-run-request.dto.d.ts +35 -0
- package/dist/dtos/test-run-request.dto.d.ts.map +1 -0
- package/dist/dtos/test-run-request.dto.js +120 -0
- package/dist/dtos/test-run-request.dto.js.map +1 -0
- package/dist/guards/access-token.guard.d.ts +5 -1
- package/dist/guards/access-token.guard.d.ts.map +1 -1
- package/dist/guards/access-token.guard.js +20 -1
- package/dist/guards/access-token.guard.js.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/active-user-data.interface.d.ts +1 -0
- package/dist/interfaces/active-user-data.interface.d.ts.map +1 -1
- package/dist/interfaces/active-user-data.interface.js.map +1 -1
- package/dist/jobs/database/test-run-queue-options-database.d.ts +8 -0
- package/dist/jobs/database/test-run-queue-options-database.d.ts.map +1 -0
- package/dist/jobs/database/test-run-queue-options-database.js +10 -0
- package/dist/jobs/database/test-run-queue-options-database.js.map +1 -0
- package/dist/jobs/database/test-run-queue-publisher-database.service.d.ts +12 -0
- package/dist/jobs/database/test-run-queue-publisher-database.service.d.ts.map +1 -0
- package/dist/jobs/database/test-run-queue-publisher-database.service.js +39 -0
- package/dist/jobs/database/test-run-queue-publisher-database.service.js.map +1 -0
- package/dist/jobs/database/test-run-queue-subscriber-database.service.d.ts +26 -0
- package/dist/jobs/database/test-run-queue-subscriber-database.service.d.ts.map +1 -0
- package/dist/jobs/database/test-run-queue-subscriber-database.service.js +120 -0
- package/dist/jobs/database/test-run-queue-subscriber-database.service.js.map +1 -0
- package/dist/resources/images/solid-core-menu-icon.png +0 -0
- package/dist/seeders/seed-data/solid-core-metadata.json +4 -2
- package/dist/services/access-token-denylist.service.d.ts +16 -0
- package/dist/services/access-token-denylist.service.d.ts.map +1 -0
- package/dist/services/access-token-denylist.service.js +74 -0
- package/dist/services/access-token-denylist.service.js.map +1 -0
- package/dist/services/authentication.service.d.ts +9 -1
- package/dist/services/authentication.service.d.ts.map +1 -1
- package/dist/services/authentication.service.js +39 -4
- package/dist/services/authentication.service.js.map +1 -1
- package/dist/services/import-transaction.service.d.ts +1 -1
- package/dist/services/import-transaction.service.d.ts.map +1 -1
- package/dist/services/import-transaction.service.js +13 -23
- package/dist/services/import-transaction.service.js.map +1 -1
- package/dist/services/module-metadata.service.js +4 -4
- package/dist/services/module-metadata.service.js.map +1 -1
- package/dist/services/settings/default-settings-provider.service.d.ts +20 -0
- package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
- package/dist/services/settings/default-settings-provider.service.js +11 -0
- package/dist/services/settings/default-settings-provider.service.js.map +1 -1
- package/dist/services/workflow-secret.service.d.ts +3 -0
- package/dist/services/workflow-secret.service.d.ts.map +1 -1
- package/dist/services/workflow-secret.service.js +25 -0
- package/dist/services/workflow-secret.service.js.map +1 -1
- package/dist/solid-core.module.d.ts.map +1 -1
- package/dist/solid-core.module.js +9 -0
- package/dist/solid-core.module.js.map +1 -1
- package/dist/testing/adapters/ui/browser-provisioner.d.ts +5 -0
- package/dist/testing/adapters/ui/browser-provisioner.d.ts.map +1 -0
- package/dist/testing/adapters/ui/browser-provisioner.js +102 -0
- package/dist/testing/adapters/ui/browser-provisioner.js.map +1 -0
- package/dist/testing/adapters/ui/playwright-adapter.d.ts +40 -1
- package/dist/testing/adapters/ui/playwright-adapter.d.ts.map +1 -1
- package/dist/testing/adapters/ui/playwright-adapter.js +185 -3
- package/dist/testing/adapters/ui/playwright-adapter.js.map +1 -1
- package/dist/testing/adapters/ui/ui.types.d.ts +6 -0
- package/dist/testing/adapters/ui/ui.types.d.ts.map +1 -1
- package/dist/testing/adapters/ui/ui.types.js.map +1 -1
- package/dist/testing/contracts/runtime-context.types.d.ts +2 -0
- package/dist/testing/contracts/runtime-context.types.d.ts.map +1 -1
- package/dist/testing/contracts/runtime-context.types.js.map +1 -1
- package/dist/testing/core/interpolation.d.ts +1 -0
- package/dist/testing/core/interpolation.d.ts.map +1 -1
- package/dist/testing/core/interpolation.js +28 -68
- package/dist/testing/core/interpolation.js.map +1 -1
- package/dist/testing/core/path-resolution.spec.d.ts +2 -0
- package/dist/testing/core/path-resolution.spec.d.ts.map +1 -0
- package/dist/testing/core/path-resolution.spec.js +106 -0
- package/dist/testing/core/path-resolution.spec.js.map +1 -0
- package/dist/testing/core/path-segments.d.ts +2 -0
- package/dist/testing/core/path-segments.d.ts.map +1 -0
- package/dist/testing/core/path-segments.js +70 -0
- package/dist/testing/core/path-segments.js.map +1 -0
- package/dist/testing/core/resource-store.d.ts.map +1 -1
- package/dist/testing/core/resource-store.js +5 -2
- package/dist/testing/core/resource-store.js.map +1 -1
- package/dist/testing/core/testing-engine.d.ts.map +1 -1
- package/dist/testing/core/testing-engine.js +16 -0
- package/dist/testing/core/testing-engine.js.map +1 -1
- package/dist/testing/reporter/artifact-sink.d.ts +14 -0
- package/dist/testing/reporter/artifact-sink.d.ts.map +1 -0
- package/dist/testing/reporter/artifact-sink.js +3 -0
- package/dist/testing/reporter/artifact-sink.js.map +1 -0
- package/dist/testing/reporter/console-reporter.d.ts.map +1 -1
- package/dist/testing/reporter/console-reporter.js +7 -0
- package/dist/testing/reporter/console-reporter.js.map +1 -1
- package/dist/testing/reporter/file-service-artifact-sink.d.ts +22 -0
- package/dist/testing/reporter/file-service-artifact-sink.d.ts.map +1 -0
- package/dist/testing/reporter/file-service-artifact-sink.js +31 -0
- package/dist/testing/reporter/file-service-artifact-sink.js.map +1 -0
- package/dist/testing/reporter/lifecycle-events.types.d.ts +67 -0
- package/dist/testing/reporter/lifecycle-events.types.d.ts.map +1 -0
- package/dist/testing/reporter/lifecycle-events.types.js +3 -0
- package/dist/testing/reporter/lifecycle-events.types.js.map +1 -0
- package/dist/testing/reporter/lifecycle-webhook-reporter.d.ts +77 -0
- package/dist/testing/reporter/lifecycle-webhook-reporter.d.ts.map +1 -0
- package/dist/testing/reporter/lifecycle-webhook-reporter.js +275 -0
- package/dist/testing/reporter/lifecycle-webhook-reporter.js.map +1 -0
- package/dist/testing/reporter/progress-reporter.d.ts +3 -3
- package/dist/testing/reporter/progress-reporter.d.ts.map +1 -1
- package/dist/testing/reporter/progress-reporter.js.map +1 -1
- package/dist/testing/reporter/reporter.types.d.ts +13 -1
- package/dist/testing/reporter/reporter.types.d.ts.map +1 -1
- package/dist/testing/reporter/reporter.types.js.map +1 -1
- package/dist/testing/runner/read-scenarios-file.d.ts +9 -0
- package/dist/testing/runner/read-scenarios-file.d.ts.map +1 -0
- package/dist/testing/runner/read-scenarios-file.js +56 -0
- package/dist/testing/runner/read-scenarios-file.js.map +1 -0
- package/dist/testing/runner/run-from-metadata.d.ts +7 -2
- package/dist/testing/runner/run-from-metadata.d.ts.map +1 -1
- package/dist/testing/runner/run-from-metadata.js +57 -6
- package/dist/testing/runner/run-from-metadata.js.map +1 -1
- package/dist/testing/steps/ui/navigation.step.d.ts.map +1 -1
- package/dist/testing/steps/ui/navigation.step.js +12 -12
- package/dist/testing/steps/ui/navigation.step.js.map +1 -1
- package/package.json +4 -2
- package/postman/instant-logout.postman_collection.json +1493 -0
- package/resources/images/solid-core-menu-icon.png +0 -0
- package/src/commands/run-tests.command.ts +3 -0
- package/src/controllers/test-run.controller.ts +44 -0
- package/src/dtos/test-run-request.dto.ts +130 -0
- package/src/guards/access-token.guard.ts +48 -0
- package/src/index.ts +21 -1
- package/src/interfaces/active-user-data.interface.ts +8 -0
- package/src/jobs/database/test-run-queue-options-database.ts +9 -0
- package/src/jobs/database/test-run-queue-publisher-database.service.ts +24 -0
- package/src/jobs/database/test-run-queue-subscriber-database.service.ts +122 -0
- package/src/seeders/seed-data/solid-core-metadata.json +4 -2
- package/src/services/access-token-denylist.service.ts +145 -0
- package/src/services/authentication.service.ts +118 -2
- package/src/services/import-transaction.service.ts +19 -36
- package/src/services/module-metadata.service.ts +4 -4
- package/src/services/settings/default-settings-provider.service.ts +12 -0
- package/src/services/workflow-secret.service.ts +61 -2
- package/src/solid-core.module.ts +11 -0
- package/src/testing/README.md +44 -1
- package/src/testing/adapters/ui/browser-provisioner.ts +115 -0
- package/src/testing/adapters/ui/playwright-adapter.ts +255 -5
- package/src/testing/adapters/ui/ui.types.ts +14 -0
- package/src/testing/contracts/runtime-context.types.ts +6 -0
- package/src/testing/core/interpolation.ts +39 -69
- package/src/testing/core/path-resolution.spec.ts +133 -0
- package/src/testing/core/path-segments.ts +81 -0
- package/src/testing/core/resource-store.ts +8 -2
- package/src/testing/core/testing-engine.ts +22 -0
- package/src/testing/reporter/artifact-sink.ts +16 -0
- package/src/testing/reporter/console-reporter.ts +9 -0
- package/src/testing/reporter/file-service-artifact-sink.ts +46 -0
- package/src/testing/reporter/lifecycle-events.types.ts +103 -0
- package/src/testing/reporter/lifecycle-webhook-reporter.ts +385 -0
- package/src/testing/reporter/progress-reporter.ts +3 -1
- package/src/testing/reporter/reporter.types.ts +10 -1
- package/src/testing/runner/read-scenarios-file.ts +44 -0
- package/src/testing/runner/run-from-metadata.ts +102 -9
- package/src/testing/steps/ui/navigation.step.ts +24 -20
- package/dist/services/access-token-storage.service.d.ts +0 -1
- package/dist/services/access-token-storage.service.d.ts.map +0 -1
- package/dist/services/access-token-storage.service.js +0 -1
- package/dist/services/access-token-storage.service.js.map +0 -1
- package/src/services/access-token-storage.service.ts +0 -0
|
@@ -40,6 +40,7 @@ import { User } from "../entities/user.entity";
|
|
|
40
40
|
import { EventDetails, EventType } from "../interfaces";
|
|
41
41
|
import { ActiveUserData } from "../interfaces/active-user-data.interface";
|
|
42
42
|
import { ActiveSessionStorageService } from "./active-session-storage.service";
|
|
43
|
+
import { AccessTokenDenylistService } from "./access-token-denylist.service";
|
|
43
44
|
import { HashingService } from "./hashing.service";
|
|
44
45
|
import {
|
|
45
46
|
InvalidatedRefreshTokenError,
|
|
@@ -77,6 +78,7 @@ export class AuthenticationService {
|
|
|
77
78
|
private readonly hashingService: HashingService,
|
|
78
79
|
private readonly jwtService: JwtService,
|
|
79
80
|
private readonly activeSessionStorage: ActiveSessionStorageService,
|
|
81
|
+
private readonly accessTokenDenylist: AccessTokenDenylistService,
|
|
80
82
|
private readonly refreshTokenIdsStorage: RefreshTokenIdsStorageService,
|
|
81
83
|
private readonly httpService: HttpService,
|
|
82
84
|
// private readonly mailService: SMTPEMailService,
|
|
@@ -2356,15 +2358,34 @@ export class AuthenticationService {
|
|
|
2356
2358
|
}
|
|
2357
2359
|
|
|
2358
2360
|
const userId = payload.sub;
|
|
2361
|
+
|
|
2359
2362
|
if (allDevices) {
|
|
2363
|
+
// Deliberately not gated on session ownership: "log out everywhere"
|
|
2364
|
+
// treats any verifiable refresh token as sufficient authority, which is
|
|
2365
|
+
// what invalidateAll already accepts.
|
|
2360
2366
|
await this.refreshTokenIdsStorage.invalidateAll(userId);
|
|
2361
|
-
|
|
2367
|
+
await this.accessTokenDenylist.revokeAllForUser(userId);
|
|
2368
|
+
} else if (await this.ownsLiveSession(userId, payload, refreshToken)) {
|
|
2362
2369
|
// Scoped to the bucket this token belongs to, so signing out on one
|
|
2363
2370
|
// device leaves the others alive. A token with no deviceKey names the
|
|
2364
2371
|
// single slot, which is the same key as before.
|
|
2365
2372
|
await this.refreshTokenIdsStorage.invalidate(userId, payload.deviceKey);
|
|
2373
|
+
await this.accessTokenDenylist.revokeSession(
|
|
2374
|
+
userId,
|
|
2375
|
+
await this.resolveRevocationKey(userId, payload),
|
|
2376
|
+
);
|
|
2377
|
+
await this.activeSessionStorage.clearActiveSession(userId);
|
|
2378
|
+
} else {
|
|
2379
|
+
// The token names a session that is already gone - superseded by a
|
|
2380
|
+
// newer login into the same bucket. Acting on it would destroy the
|
|
2381
|
+
// session that replaced it. Nothing to do, but log it: the failure is
|
|
2382
|
+
// otherwise invisible, and a cluster of these means clients are losing
|
|
2383
|
+
// token state and their logouts are no-ops.
|
|
2384
|
+
this.logger.warn(
|
|
2385
|
+
`logout: refresh token does not match the live session for user ${userId}; nothing invalidated`,
|
|
2386
|
+
);
|
|
2366
2387
|
}
|
|
2367
|
-
|
|
2388
|
+
|
|
2368
2389
|
const user = await this.userRepository.findOne({
|
|
2369
2390
|
where: {
|
|
2370
2391
|
id: userId,
|
|
@@ -2387,6 +2408,101 @@ export class AuthenticationService {
|
|
|
2387
2408
|
}
|
|
2388
2409
|
}
|
|
2389
2410
|
|
|
2411
|
+
/**
|
|
2412
|
+
* Whether this refresh token belongs to the session that currently occupies
|
|
2413
|
+
* its bucket.
|
|
2414
|
+
*
|
|
2415
|
+
* A per-device bucket holds exactly one session - deviceKey is a fresh
|
|
2416
|
+
* randomUUID per login - so a token carrying one is that session by
|
|
2417
|
+
* construction and needs no further proof. Only the bare `user-<id>` bucket
|
|
2418
|
+
* is shared across logins (preventConcurrentLogins on, or a pre-migration
|
|
2419
|
+
* token), and that is the sole case where a stale token can name a session
|
|
2420
|
+
* that has since been replaced. Without this, a laptop whose session was
|
|
2421
|
+
* superseded by a phone login would, on logging out, destroy the phone's
|
|
2422
|
+
* session instead of its own.
|
|
2423
|
+
*
|
|
2424
|
+
* Matches current OR previous, and deliberately does not consult
|
|
2425
|
+
* isWithinGraceWindow: that window governs whether a rotated-out token may
|
|
2426
|
+
* still be exchanged, whereas the question here is only which session the
|
|
2427
|
+
* token belongs to - which stays true however long ago it rotated.
|
|
2428
|
+
*
|
|
2429
|
+
* Missing state means it owns nothing. Safe, because the refresh state and
|
|
2430
|
+
* active-session entry share one cache: if the state is gone the session
|
|
2431
|
+
* entry is too, so the actions being skipped were no-ops anyway.
|
|
2432
|
+
*/
|
|
2433
|
+
private async ownsLiveSession(
|
|
2434
|
+
userId: number,
|
|
2435
|
+
payload: { deviceKey?: string },
|
|
2436
|
+
refreshToken: string,
|
|
2437
|
+
): Promise<boolean> {
|
|
2438
|
+
if (payload.deviceKey) {
|
|
2439
|
+
return true;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
const state =
|
|
2443
|
+
await this.refreshTokenIdsStorage.getCurrentRefreshTokenState(userId);
|
|
2444
|
+
return (
|
|
2445
|
+
state?.currentRefreshToken === refreshToken ||
|
|
2446
|
+
(!!state?.previousRefreshToken &&
|
|
2447
|
+
state.previousRefreshToken === refreshToken)
|
|
2448
|
+
);
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
/**
|
|
2452
|
+
* The key a session's access tokens are denylisted under. Mirrors how the
|
|
2453
|
+
* access token itself is stamped: deviceKey while concurrent logins are
|
|
2454
|
+
* allowed, sessionId while they are not - and in the latter case only one
|
|
2455
|
+
* session exists, so the recorded active session IS this session's id.
|
|
2456
|
+
*
|
|
2457
|
+
* Must be read before clearActiveSession, which deletes the value it reads.
|
|
2458
|
+
*/
|
|
2459
|
+
private async resolveRevocationKey(
|
|
2460
|
+
userId: number,
|
|
2461
|
+
payload: { deviceKey?: string },
|
|
2462
|
+
): Promise<string | undefined> {
|
|
2463
|
+
return (
|
|
2464
|
+
payload.deviceKey ??
|
|
2465
|
+
(await this.activeSessionStorage.getActiveSession(userId))
|
|
2466
|
+
);
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
/**
|
|
2470
|
+
* Public seam for consuming projects that need a logout of their own shape -
|
|
2471
|
+
* for instance revoking the access token while deliberately leaving the
|
|
2472
|
+
* refresh token valid.
|
|
2473
|
+
*
|
|
2474
|
+
* Verifies the refresh token (signature/audience/issuer enforced, expiry
|
|
2475
|
+
* tolerated), confirms it owns the live session, and returns the key its
|
|
2476
|
+
* access tokens are denylisted under.
|
|
2477
|
+
*
|
|
2478
|
+
* null - unverifiable: forged, malformed, or an access
|
|
2479
|
+
* token presented here
|
|
2480
|
+
* revocationKey undefined - verified, but nothing safe to revoke: either a
|
|
2481
|
+
* superseded session, or no session key could be
|
|
2482
|
+
* resolved. userId is still returned so the caller
|
|
2483
|
+
* can log the event or call revokeAllForUser.
|
|
2484
|
+
*
|
|
2485
|
+
* logout() uses this same method, so the seam cannot silently rot.
|
|
2486
|
+
*/
|
|
2487
|
+
async resolveSessionRevocationKey(
|
|
2488
|
+
refreshToken: string,
|
|
2489
|
+
): Promise<{ userId: number; revocationKey?: string } | null> {
|
|
2490
|
+
const payload = await this.verifyRefreshTokenForLogout(refreshToken);
|
|
2491
|
+
if (!payload?.sub) {
|
|
2492
|
+
return null;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
const userId = payload.sub;
|
|
2496
|
+
if (!(await this.ownsLiveSession(userId, payload, refreshToken))) {
|
|
2497
|
+
return { userId };
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
return {
|
|
2501
|
+
userId,
|
|
2502
|
+
revocationKey: await this.resolveRevocationKey(userId, payload),
|
|
2503
|
+
};
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2390
2506
|
private async verifyRefreshTokenForLogout(
|
|
2391
2507
|
refreshToken: string,
|
|
2392
2508
|
): Promise<{ sub: number; deviceKey?: string } | null> {
|
|
@@ -607,12 +607,28 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
|
|
|
607
607
|
//FIXME Currently below method fails if any field in the record is not valid or if the record is not valid. It does not collect the errors for all fields in a record
|
|
608
608
|
private async insertRecord(record: Record<string, any>, mapping: ImportMapping[], modelMetadataWithFields: ModelMetadata, modelService: CRUDService<any>): Promise<any> {
|
|
609
609
|
// Convert the imported record to a DTO
|
|
610
|
-
const dto = await this.convertImportedRecordToDto(record, mapping, modelMetadataWithFields);
|
|
610
|
+
const dto = await this.convertImportedRecordToDto(record, mapping, modelMetadataWithFields, modelService);
|
|
611
611
|
// Use the model service to create the record in the database
|
|
612
612
|
const createdRecord = await modelService.create(dto, [], {}); //FIXME: Need to handle this part alongwith the refactoring of the CRUDService for permissions
|
|
613
613
|
return createdRecord; // Return the created record
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
+
// Import files do not contain hidden internal fields. Add the entity's
|
|
617
|
+
// defaults before CRUD validation so those fields do not fail as missing.
|
|
618
|
+
private applyImportDefaults(dto: Record<string, any>, modelService: CRUDService<any>): void {
|
|
619
|
+
for (const column of modelService.repo.metadata.columns) {
|
|
620
|
+
if (dto[column.propertyName] !== undefined || column.default === undefined) {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Leave SQL expression defaults (such as CURRENT_TIMESTAMP) for the
|
|
625
|
+
// database instead of putting the expression text into the DTO.
|
|
626
|
+
if (typeof column.default !== 'function') {
|
|
627
|
+
dto[column.propertyName] = column.default;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
616
632
|
private getModelService(modelSingularName: string): CRUDService<any> {
|
|
617
633
|
// Get the model service for the model metadata name
|
|
618
634
|
const modelServiceWrapper = this.introspectService.getProvider(`${classify(modelSingularName)}Service`);
|
|
@@ -623,7 +639,7 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
|
|
|
623
639
|
return modelService;
|
|
624
640
|
}
|
|
625
641
|
|
|
626
|
-
private async convertImportedRecordToDto(record: Record<string, any>, mapping: ImportMapping[], modelMetadataWithFields: ModelMetadata) {
|
|
642
|
+
private async convertImportedRecordToDto(record: Record<string, any>, mapping: ImportMapping[], modelMetadataWithFields: ModelMetadata, modelService: CRUDService<any>) {
|
|
627
643
|
// Create a new record object
|
|
628
644
|
const dtoRecord: Record<string, any> = {};
|
|
629
645
|
|
|
@@ -644,43 +660,10 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
|
|
|
644
660
|
}
|
|
645
661
|
}
|
|
646
662
|
|
|
647
|
-
|
|
648
|
-
// have to enter internal values such as passwordSchemeVersion. The
|
|
649
|
-
// backend still checks those fields as required, so fill in their default
|
|
650
|
-
// values here before validation. This lets users import normal details
|
|
651
|
-
// without exposing or requiring internal fields in the template.
|
|
652
|
-
for (const field of modelMetadataWithFields.fields) {
|
|
653
|
-
if (
|
|
654
|
-
field.required &&
|
|
655
|
-
field.defaultValue !== null &&
|
|
656
|
-
field.defaultValue !== undefined &&
|
|
657
|
-
!(field.name in dtoRecord)
|
|
658
|
-
) {
|
|
659
|
-
dtoRecord[field.name] = this.parseImportDefaultValue(field);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
+
this.applyImportDefaults(dtoRecord, modelService);
|
|
663
664
|
return dtoRecord;
|
|
664
665
|
}
|
|
665
666
|
|
|
666
|
-
// Defaults come from metadata as text. Convert them to the type expected by
|
|
667
|
-
// validation, so values like "1" and "true" are handled as a number and a
|
|
668
|
-
// boolean instead of being treated as plain text.
|
|
669
|
-
private parseImportDefaultValue(field: FieldMetadata): any {
|
|
670
|
-
const defaultValue = field.defaultValue;
|
|
671
|
-
|
|
672
|
-
switch (field.type) {
|
|
673
|
-
case SolidFieldType.boolean:
|
|
674
|
-
return ['true', '1', 'yes'].includes(String(defaultValue).toLowerCase());
|
|
675
|
-
case SolidFieldType.int:
|
|
676
|
-
case SolidFieldType.bigint:
|
|
677
|
-
case SolidFieldType.decimal:
|
|
678
|
-
return Number(defaultValue);
|
|
679
|
-
default:
|
|
680
|
-
return defaultValue;
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
667
|
private async populateDtoForACell(dtoRecord: Record<string, any>, fieldMetadata: FieldMetadata, record: Record<string, any>, key: string): Promise<Record<string, any>> {
|
|
685
668
|
const fieldType = fieldMetadata.type;
|
|
686
669
|
// const userKeyFieldName = userKeyField?.name || 'id'; // Default to 'id' if not found
|
|
@@ -142,8 +142,8 @@ export class ModuleMetadataService {
|
|
|
142
142
|
throw new BadRequestException('Dangerous file types are not allowed for the menu icon.');
|
|
143
143
|
}
|
|
144
144
|
const fileStoragePath = this.getFullFilePathForDisk(this.getFileName(files[0]));
|
|
145
|
-
this.fileService.copy(files[0].path, fileStoragePath);
|
|
146
|
-
this.fileService.delete(files[0].path);
|
|
145
|
+
await this.fileService.copy(files[0].path, fileStoragePath);
|
|
146
|
+
await this.fileService.delete(files[0].path);
|
|
147
147
|
createDto.menuIconUrl = fileStoragePath;
|
|
148
148
|
}
|
|
149
149
|
const moduleMetadata = this.moduleMetadataRepo.create(createDto);
|
|
@@ -250,8 +250,8 @@ export class ModuleMetadataService {
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
const fileStoragePath = this.getFullFilePathForDisk(this.getFileName(files[0]));
|
|
253
|
-
this.fileService.copy(files[0].path, fileStoragePath);
|
|
254
|
-
this.fileService.delete(files[0].path);
|
|
253
|
+
await this.fileService.copy(files[0].path, fileStoragePath);
|
|
254
|
+
await this.fileService.delete(files[0].path);
|
|
255
255
|
module.menuIconUrl = fileStoragePath;
|
|
256
256
|
}
|
|
257
257
|
return manager.save(ModuleMetadata, module);
|
|
@@ -1263,6 +1263,18 @@ const getSolidCoreSettings = (isProd: boolean) =>
|
|
|
1263
1263
|
sortOrder: 200,
|
|
1264
1264
|
controlType: "boolean",
|
|
1265
1265
|
},
|
|
1266
|
+
{
|
|
1267
|
+
moduleName: "solid-core",
|
|
1268
|
+
key: "invalidateAccessTokenOnLogout",
|
|
1269
|
+
value: false,
|
|
1270
|
+
level: SettingLevel.SystemAdminEditable,
|
|
1271
|
+
label: "Invalidate Access Token On Logout",
|
|
1272
|
+
group: "authentication-settings",
|
|
1273
|
+
sortOrder: 205,
|
|
1274
|
+
controlType: "boolean",
|
|
1275
|
+
helpText:
|
|
1276
|
+
"Rejects a session's access token on the next request after logout, instead of letting it live out its remaining lifetime. Adds a cache lookup to every authenticated request, and needs Redis configured to take effect across multiple replicas.",
|
|
1277
|
+
},
|
|
1266
1278
|
{
|
|
1267
1279
|
moduleName: "solid-core",
|
|
1268
1280
|
key: "mpinEnabled",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
|
1
|
+
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
2
2
|
import { InjectEntityManager } from '@nestjs/typeorm';
|
|
3
3
|
import { ModuleRef } from "@nestjs/core";
|
|
4
|
-
import { EntityManager } from 'typeorm';
|
|
4
|
+
import { EntityManager, In } from 'typeorm';
|
|
5
5
|
import { CRUDService } from 'src/services/crud.service';
|
|
6
6
|
import { WorkflowSecret } from '../entities/workflow-secret.entity';
|
|
7
7
|
import { WorkflowSecretRepository } from '../repository/workflow-secret.repository';
|
|
@@ -88,6 +88,65 @@ export class WorkflowSecretService extends CRUDService<WorkflowSecret> {
|
|
|
88
88
|
return context;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Resolves one secret to its decrypted, type-coerced value.
|
|
93
|
+
*
|
|
94
|
+
* `key` is the literal key column value — "smtp.password" is one row, not a path.
|
|
95
|
+
* Callers must resolve at point of use and must not cache the result.
|
|
96
|
+
*/
|
|
97
|
+
async resolve(key: string): Promise<any> {
|
|
98
|
+
const resolved = await this.resolveMany([key]);
|
|
99
|
+
return resolved[key];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolves whatever of `keys` exists and is active, in a single query, omitting the
|
|
104
|
+
* rest. Returns a flat map keyed by the literal keys requested.
|
|
105
|
+
*
|
|
106
|
+
* For callers that can report a missing key better than this service can — the test
|
|
107
|
+
* runner lets an unresolved key surface at the step that references it, which names
|
|
108
|
+
* the token and locates the failure. Prefer resolveMany elsewhere.
|
|
109
|
+
*
|
|
110
|
+
* Still fail-closed on infrastructure: an absent APP_ENCRYPTION_KEY or a failed
|
|
111
|
+
* decrypt throws rather than yielding a partial map.
|
|
112
|
+
*/
|
|
113
|
+
async resolveAvailable(keys: string[]): Promise<Record<string, any>> {
|
|
114
|
+
const uniqueKeys = Array.from(new Set((keys ?? []).filter(Boolean)));
|
|
115
|
+
if (uniqueKeys.length === 0) {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const secrets = await this.repo.find({
|
|
120
|
+
where: { key: In(uniqueKeys), status: "active" } as any,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return secrets.reduce((resolved, secret) => {
|
|
124
|
+
resolved[secret.key] = this.decryptAndCoerce(secret);
|
|
125
|
+
return resolved;
|
|
126
|
+
}, {} as Record<string, any>);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolves several secrets in a single query, returning a flat map keyed by the
|
|
131
|
+
* literal keys requested.
|
|
132
|
+
*
|
|
133
|
+
* Fail-closed: throws for a key that is missing, inactive or undecryptable rather
|
|
134
|
+
* than omitting it from the result. A credential that silently resolves to undefined
|
|
135
|
+
* becomes an empty password on an outbound request.
|
|
136
|
+
*/
|
|
137
|
+
async resolveMany(keys: string[]): Promise<Record<string, any>> {
|
|
138
|
+
const resolved = await this.resolveAvailable(keys);
|
|
139
|
+
|
|
140
|
+
const missing = Array.from(new Set((keys ?? []).filter(Boolean))).filter(
|
|
141
|
+
(key) => !(key in resolved),
|
|
142
|
+
);
|
|
143
|
+
if (missing.length > 0) {
|
|
144
|
+
throw new NotFoundException(`No active secret found for: ${missing.join(', ')}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return resolved;
|
|
148
|
+
}
|
|
149
|
+
|
|
91
150
|
private prepareSecretForSave(dto: any, requireValue: boolean): any {
|
|
92
151
|
const preparedDto = { ...dto };
|
|
93
152
|
const rawValue = preparedDto.value;
|
package/src/solid-core.module.ts
CHANGED
|
@@ -100,6 +100,7 @@ import { OTPAuthenticationController } from "./controllers/otp-authentication.co
|
|
|
100
100
|
import { ServiceController } from "./controllers/service.controller";
|
|
101
101
|
import { SmsTemplateController } from "./controllers/sms-template.controller";
|
|
102
102
|
import { TestQueueController } from "./controllers/test-queue.controller";
|
|
103
|
+
import { TestRunController } from "./controllers/test-run.controller";
|
|
103
104
|
import { EmailAttachment } from "./entities/email-attachment.entity";
|
|
104
105
|
import { EmailTemplate } from "./entities/email-template.entity";
|
|
105
106
|
import { MenuItemMetadata } from "./entities/menu-item-metadata.entity";
|
|
@@ -117,6 +118,8 @@ import { ApiEmailQueuePublisher } from "./jobs/rabbitmq/api-email-publisher.serv
|
|
|
117
118
|
import { ApiEmailQueueSubscriber } from "./jobs/rabbitmq/api-email-subscriber.service";
|
|
118
119
|
import { TestQueuePublisherDatabase } from "./jobs/database/test-queue-publisher-database.service";
|
|
119
120
|
import { TestQueueSubscriberDatabase } from "./jobs/database/test-queue-subscriber-database.service";
|
|
121
|
+
import { TestRunQueuePublisherDatabase } from "./jobs/database/test-run-queue-publisher-database.service";
|
|
122
|
+
import { TestRunQueueSubscriberDatabase } from "./jobs/database/test-run-queue-subscriber-database.service";
|
|
120
123
|
import { TestQueuePublisherRedis } from "./jobs/redis/test-queue-publisher-redis.service";
|
|
121
124
|
import { TestQueueSubscriberRedis } from "./jobs/redis/test-queue-subscriber-redis.service";
|
|
122
125
|
import { Msg91WhatsappQueuePublisher } from "./jobs/rabbitmq/msg91-whatsapp-publisher.service";
|
|
@@ -167,6 +170,7 @@ import { GoogleOauthStrategy } from "./passport-strategies/google-oauth.strategy
|
|
|
167
170
|
import { ApiKeyService } from "./services/api-key.service";
|
|
168
171
|
import { MpinService } from "./services/mpin.service";
|
|
169
172
|
import { ActiveSessionStorageService } from "./services/active-session-storage.service";
|
|
173
|
+
import { AccessTokenDenylistService } from "./services/access-token-denylist.service";
|
|
170
174
|
import { AuthenticationService } from "./services/authentication.service";
|
|
171
175
|
import { MetadataValidationService } from "./services/metadata-validation.service";
|
|
172
176
|
import { BcryptService } from "./services/bcrypt.service";
|
|
@@ -626,6 +630,7 @@ import { SwitchNode } from './services/workflow/nodes/switch.node';
|
|
|
626
630
|
SmsTemplateController,
|
|
627
631
|
TestController,
|
|
628
632
|
TestQueueController,
|
|
633
|
+
TestRunController,
|
|
629
634
|
UserActivityHistoryController,
|
|
630
635
|
UserController,
|
|
631
636
|
UserViewMetadataController,
|
|
@@ -778,6 +783,7 @@ import { SwitchNode } from './services/workflow/nodes/switch.node';
|
|
|
778
783
|
ApiKeyService,
|
|
779
784
|
MpinService,
|
|
780
785
|
ActiveSessionStorageService,
|
|
786
|
+
AccessTokenDenylistService,
|
|
781
787
|
AuthenticationService,
|
|
782
788
|
GoogleAuthenticationController,
|
|
783
789
|
RefreshTokenIdsStorageService,
|
|
@@ -802,6 +808,8 @@ import { SwitchNode } from './services/workflow/nodes/switch.node';
|
|
|
802
808
|
|
|
803
809
|
TestQueuePublisherDatabase,
|
|
804
810
|
TestQueueSubscriberDatabase,
|
|
811
|
+
TestRunQueuePublisherDatabase,
|
|
812
|
+
TestRunQueueSubscriberDatabase,
|
|
805
813
|
TestQueuePublisherRedis,
|
|
806
814
|
TestQueueSubscriberRedis,
|
|
807
815
|
ApiEmailQueuePublisherRedis,
|
|
@@ -996,6 +1004,9 @@ import { SwitchNode } from './services/workflow/nodes/switch.node';
|
|
|
996
1004
|
SolidXSendSmsNode,
|
|
997
1005
|
],
|
|
998
1006
|
exports: [
|
|
1007
|
+
// Exported for DI, not merely re-exported from index.ts: a consuming
|
|
1008
|
+
// project composing its own logout has to be able to inject this.
|
|
1009
|
+
AccessTokenDenylistService,
|
|
999
1010
|
AuthenticationService,
|
|
1000
1011
|
ChatterMessageDetailsRepository,
|
|
1001
1012
|
ChatterMessageDetailsService,
|
package/src/testing/README.md
CHANGED
|
@@ -8,6 +8,21 @@
|
|
|
8
8
|
- `reporter/`: reporting interfaces + console reporter
|
|
9
9
|
- `runner/`: lifecycle helpers + metadata runner
|
|
10
10
|
|
|
11
|
+
## Browser Setup
|
|
12
|
+
UI scenarios need a Chromium build. The npm `playwright` package ships no install script, so
|
|
13
|
+
the binary is fetched separately.
|
|
14
|
+
|
|
15
|
+
- **By default it is lazy.** The first test run that selects a UI scenario downloads Chromium
|
|
16
|
+
(~150MB) before the run starts, then reuses it. Runs filtered to API-only scenarios never
|
|
17
|
+
download anything, and neither do projects that have no UI scenarios.
|
|
18
|
+
- **To fetch it at install time instead**, set `COMMON_UI_TEST_BROWSERS_EAGER_INSTALL=true`
|
|
19
|
+
either in the API project's `.env` or as a real environment variable. Useful for CI images.
|
|
20
|
+
A failed download never fails the install. Note that `npm install --ignore-scripts` skips
|
|
21
|
+
this entirely, in which case the lazy path still applies.
|
|
22
|
+
- **In Docker**, browsers land in `~/.cache/ms-playwright` (Linux). A multi-stage build that
|
|
23
|
+
installs as `root` but runs as another user must set `PLAYWRIGHT_BROWSERS_PATH` to a shared
|
|
24
|
+
location, or the runtime user will not find the binary.
|
|
25
|
+
|
|
11
26
|
## Metadata Shape
|
|
12
27
|
`TestingMetadata` lives under `testing` in module metadata JSON files.
|
|
13
28
|
|
|
@@ -252,11 +267,14 @@ Options in `with`:
|
|
|
252
267
|
- `timeoutMs` (optional, overrides the run-wide UI navigation timeout)
|
|
253
268
|
|
|
254
269
|
### **Op: `ui.expectUrl`**
|
|
255
|
-
Description:
|
|
270
|
+
Description: Waits for the page URL to match, then asserts it. Backed by `page.waitForURL`, so an
|
|
271
|
+
assertion placed straight after a navigating click does not race the navigation. Returns
|
|
272
|
+
immediately when the URL already matches.
|
|
256
273
|
|
|
257
274
|
Options in `with`:
|
|
258
275
|
- `equals` (optional)
|
|
259
276
|
- `contains` (optional)
|
|
277
|
+
- `timeoutMs` (optional, overrides the run-wide UI navigation timeout)
|
|
260
278
|
|
|
261
279
|
### **Op: `ui.fill`**
|
|
262
280
|
Description: Fills an input or editable element.
|
|
@@ -289,6 +307,19 @@ Options in `with`:
|
|
|
289
307
|
- `key` (required)
|
|
290
308
|
- `timeoutMs` (optional, overrides the run-wide UI timeout)
|
|
291
309
|
|
|
310
|
+
### **Op: `ui.uploadFile`**
|
|
311
|
+
Description: Sets one or more files on a file input.
|
|
312
|
+
|
|
313
|
+
Options in `with`:
|
|
314
|
+
- `selector` (required, the `<input type="file">`)
|
|
315
|
+
- `filePath` (required, a path or an array of paths)
|
|
316
|
+
- `timeoutMs` (optional, overrides the run-wide UI timeout)
|
|
317
|
+
|
|
318
|
+
Notes:
|
|
319
|
+
- Paths are resolved against the run's working directory, so a relative path is relative to where
|
|
320
|
+
`solid test run` was invoked.
|
|
321
|
+
- Pass an array to populate a `multiple` input; a single string is the common case.
|
|
322
|
+
|
|
292
323
|
### **Op: `ui.waitForManual`**
|
|
293
324
|
Description: Pauses a headed Playwright run so a human can interact with the live browser, then resumes when Enter is pressed in the terminal.
|
|
294
325
|
|
|
@@ -327,6 +358,18 @@ Options in `with`:
|
|
|
327
358
|
- `selector` (required)
|
|
328
359
|
- `timeoutMs` (optional, overrides the run-wide UI timeout)
|
|
329
360
|
|
|
361
|
+
### **Op: `ui.expectHidden`**
|
|
362
|
+
Description: Waits for an element to be hidden — i.e. detached or no longer visible.
|
|
363
|
+
|
|
364
|
+
Options in `with`:
|
|
365
|
+
- `selector` (required)
|
|
366
|
+
- `timeoutMs` (optional, overrides the run-wide UI timeout)
|
|
367
|
+
|
|
368
|
+
Notes:
|
|
369
|
+
- The counterpart to `ui.expectVisible`: use it to wait for a modal, spinner, toast or overlay to
|
|
370
|
+
**go away** before the next step acts. Prefer it over `util.sleep` for that — it returns the moment
|
|
371
|
+
the element disappears instead of always spending the full duration.
|
|
372
|
+
|
|
330
373
|
### **Op: `ui.expectText`**
|
|
331
374
|
Description: Asserts the text content of an element.
|
|
332
375
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { ChildProcess, spawn } from "child_process";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The npm `playwright` package ships no install script, so `npm install` places the
|
|
7
|
+
* JS module but never downloads the browser binary. These helpers fetch it on demand.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
let inFlight: Promise<void> | null = null;
|
|
11
|
+
let activeInstall: ChildProcess | null = null;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolves Playwright's own CLI entry point.
|
|
15
|
+
*
|
|
16
|
+
* `require.resolve('playwright/cli.js')` fails with ERR_PACKAGE_PATH_NOT_EXPORTED because
|
|
17
|
+
* `cli.js` is absent from Playwright's `exports` map, so we resolve `package.json` (which
|
|
18
|
+
* is exported) and walk to its sibling.
|
|
19
|
+
*/
|
|
20
|
+
export function resolvePlaywrightCli(): string | null {
|
|
21
|
+
try {
|
|
22
|
+
const packageJsonPath = require.resolve("playwright/package.json");
|
|
23
|
+
const cliPath = path.join(path.dirname(packageJsonPath), "cli.js");
|
|
24
|
+
return fs.existsSync(cliPath) ? cliPath : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Asking Playwright for the executable path keeps us in step with PLAYWRIGHT_BROWSERS_PATH
|
|
32
|
+
* and its per-version cache layout instead of second-guessing either.
|
|
33
|
+
*/
|
|
34
|
+
export async function isChromiumInstalled(): Promise<boolean> {
|
|
35
|
+
try {
|
|
36
|
+
const { chromium } = await import("playwright");
|
|
37
|
+
return fs.existsSync(chromium.executablePath());
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Downloads Chromium unless it is already present. Only Chromium is fetched: it is the
|
|
45
|
+
* sole browser PlaywrightAdapter launches.
|
|
46
|
+
*
|
|
47
|
+
* Concurrent callers share one download. This matters once a server warms the browser up
|
|
48
|
+
* at boot while a test run may start before that finishes.
|
|
49
|
+
*/
|
|
50
|
+
export function ensureChromiumInstalled(): Promise<void> {
|
|
51
|
+
if (!inFlight) {
|
|
52
|
+
// Clearing on settle rather than caching the result lets a failed download be retried
|
|
53
|
+
// by the next caller instead of poisoning the whole process lifetime.
|
|
54
|
+
inFlight = installChromium().finally(() => {
|
|
55
|
+
inFlight = null;
|
|
56
|
+
activeInstall = null;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return inFlight;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Stops a download that is still running, so shutting down mid-install does not orphan the
|
|
64
|
+
* child process against a half-written browser cache.
|
|
65
|
+
*/
|
|
66
|
+
export function cancelChromiumInstall(): void {
|
|
67
|
+
activeInstall?.kill("SIGTERM");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function installChromium(): Promise<void> {
|
|
71
|
+
if (await isChromiumInstalled()) return;
|
|
72
|
+
|
|
73
|
+
const cliPath = resolvePlaywrightCli();
|
|
74
|
+
if (!cliPath) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
"Chromium is required for UI scenarios but the playwright package could not be resolved. " +
|
|
77
|
+
"Install it in the API project with: npm install playwright",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log(
|
|
82
|
+
"Chromium is required for UI scenarios and is not installed yet. Downloading it now (~150MB, one time)...",
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
await new Promise<void>((resolve, reject) => {
|
|
86
|
+
// Async spawn, never spawnSync: this also runs inside a live server during module init,
|
|
87
|
+
// where a synchronous child would block the event loop for the whole download.
|
|
88
|
+
const child = spawn(process.execPath, [cliPath, "install", "chromium"], {
|
|
89
|
+
stdio: "inherit",
|
|
90
|
+
});
|
|
91
|
+
activeInstall = child;
|
|
92
|
+
|
|
93
|
+
child.on("error", (error) =>
|
|
94
|
+
reject(
|
|
95
|
+
new Error(
|
|
96
|
+
`Failed to download Chromium: ${error.message}. ` +
|
|
97
|
+
"Install it manually with: npx playwright install chromium",
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
child.on("close", (code, signal) => {
|
|
103
|
+
if (code === 0) {
|
|
104
|
+
resolve();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
reject(
|
|
108
|
+
new Error(
|
|
109
|
+
`Chromium download ${signal ? `was stopped by ${signal}` : `exited with code ${code}`}. ` +
|
|
110
|
+
"Install it manually with: npx playwright install chromium",
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|