@gobing-ai/ts-browser-automation 0.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -0
- package/dist/errors.d.ts +42 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +60 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/launcher.d.ts +43 -0
- package/dist/launcher.d.ts.map +1 -0
- package/dist/launcher.js +83 -0
- package/dist/operations.d.ts +64 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/operations.js +124 -0
- package/dist/profile-dir.d.ts +8 -0
- package/dist/profile-dir.d.ts.map +1 -0
- package/dist/profile-dir.js +18 -0
- package/package.json +63 -0
- package/src/errors.ts +72 -0
- package/src/index.ts +18 -0
- package/src/launcher.ts +120 -0
- package/src/operations.ts +180 -0
- package/src/profile-dir.ts +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @gobing-ai/ts-browser-automation
|
|
2
|
+
|
|
3
|
+
Reusable Playwright persistent browser profile sessions: sign in once with a
|
|
4
|
+
dedicated visible Chromium profile, then drive later automation headless against
|
|
5
|
+
the same profile. Extracted from knowledge-kit's XHS publish flow (task 0088,
|
|
6
|
+
[ADR-032](../../docs/00_ADR.md)).
|
|
7
|
+
|
|
8
|
+
Part of the `@gobing-ai/ts-libs` monorepo and lockstep-versioned with it.
|
|
9
|
+
|
|
10
|
+
- **License:** Apache-2.0.
|
|
11
|
+
- The package never stores usernames/passwords and never exports cookies — the
|
|
12
|
+
Playwright user-data directory itself is the login artifact.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Install & runtime requirements
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
bun add @gobing-ai/ts-browser-automation
|
|
20
|
+
# Playwright is a required peer dependency (^1.55.0):
|
|
21
|
+
bun add playwright@^1.55.0
|
|
22
|
+
# Chromium is owned by the consumer (ADR-032):
|
|
23
|
+
npx playwright install chromium
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- **Runtime:** Node.js or Bun with Playwright's supported platforms.
|
|
27
|
+
- A missing `playwright` peer or missing Chromium fails with a typed
|
|
28
|
+
`PlaywrightUnavailableError` at launch time — never at import. Importing the
|
|
29
|
+
module has no browser side effect.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
### 1. One-time headed login into a dedicated profile
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { loginWithProfile } from '@gobing-ai/ts-browser-automation';
|
|
37
|
+
|
|
38
|
+
// You choose where the profile lives — the package has no default location.
|
|
39
|
+
const profileDir = `${process.env.HOME}/.local/share/myapp/browser-profiles/xhs`;
|
|
40
|
+
|
|
41
|
+
await loginWithProfile({
|
|
42
|
+
profileDir,
|
|
43
|
+
url: 'https://example-site.test/login',
|
|
44
|
+
// Site-owned readiness check: only your predicate can declare success.
|
|
45
|
+
isAuthenticated: async (page) => page.url().includes('/dashboard'),
|
|
46
|
+
timeoutMs: 5 * 60_000,
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A visible browser opens; sign in by hand. The context closes once your
|
|
51
|
+
predicate passes — or on timeout, cancellation (`signal`), or error. If the
|
|
52
|
+
deadline expires you get `LoginTimeoutError` with a re-login instruction; just
|
|
53
|
+
run `loginWithProfile` again with the same `profileDir`.
|
|
54
|
+
|
|
55
|
+
### 2. Later automation reusing the authenticated profile
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { withProfilePage } from '@gobing-ai/ts-browser-automation';
|
|
59
|
+
|
|
60
|
+
const result = await withProfilePage(
|
|
61
|
+
{
|
|
62
|
+
profileDir,
|
|
63
|
+
headless: true,
|
|
64
|
+
url: 'https://example-site.test/feed',
|
|
65
|
+
// Guard the claim of an authenticated session (task 0088 R3):
|
|
66
|
+
isAuthenticated: (page) => page.url().includes('/feed'),
|
|
67
|
+
timeoutMs: 30_000,
|
|
68
|
+
},
|
|
69
|
+
async ({ context, page }) => {
|
|
70
|
+
// Full Playwright page/locator/upload APIs. Site-specific CDP is
|
|
71
|
+
// caller-owned, e.g. for closed-shadow DOM control:
|
|
72
|
+
const cdp = await context.newCDPSession(page);
|
|
73
|
+
return page.locator('#app').innerText();
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`withProfilePage` guarantees `context.close()` in a `finally` on every exit
|
|
79
|
+
path. For full manual control (e.g. keeping one context across many steps),
|
|
80
|
+
use the low-level `openPersistentBrowser({ profileDir, headless })` and close
|
|
81
|
+
the returned `BrowserContext` yourself.
|
|
82
|
+
|
|
83
|
+
## Behavior guarantees
|
|
84
|
+
|
|
85
|
+
- **Dedicated profile, explicit path.** `profileDir` is required (empty/blank
|
|
86
|
+
or an existing file → `InvalidProfileDirError`) and resolved to an absolute
|
|
87
|
+
path. Relative input resolves against the current working directory.
|
|
88
|
+
- **Owner-only creation.** A missing profile directory is created with `0o700`
|
|
89
|
+
on a headed launch, on local filesystems that enforce permission bits (POSIX;
|
|
90
|
+
mode bits are a no-op on Windows). Existing directories are adopted in place —
|
|
91
|
+
never moved, re-permissioned, or deleted.
|
|
92
|
+
- **No silent unauthenticated sessions.** A headless run against a missing
|
|
93
|
+
profile fails with `BrowserProfileMissingError` instead of creating a fresh,
|
|
94
|
+
unauthenticated one.
|
|
95
|
+
- **Single-process rule.** Playwright user-data directories hold session state
|
|
96
|
+
and cannot be opened by concurrent browser instances. One profile = one
|
|
97
|
+
browser at a time; concurrent use surfaces as `BrowserProfileBusyError`.
|
|
98
|
+
- **Auth expiry is recoverable.** A profile whose site session has expired
|
|
99
|
+
fails the readiness predicate with `LoginTimeoutError` (message includes the
|
|
100
|
+
re-login instruction) — it never reports a false authenticated result.
|
|
101
|
+
- **Sensitive material.** Cookies and local storage in the profile are session
|
|
102
|
+
material: never log profile contents, never commit a profile directory.
|
|
103
|
+
|
|
104
|
+
## Caveats
|
|
105
|
+
|
|
106
|
+
- **Some sites reject headless automation.** The headed path always remains
|
|
107
|
+
available for debugging (`headless: false` or `loginWithProfile`); the
|
|
108
|
+
package does not promise headless success on every site.
|
|
109
|
+
- **App-specific choices stay outside the package** — no default profile
|
|
110
|
+
location, site selectors, or credentials. Callers own all of them.
|
|
111
|
+
|
|
112
|
+
## Manual smoke (local, never unattended)
|
|
113
|
+
|
|
114
|
+
1. Pick a disposable account and a profile directory outside this repository:
|
|
115
|
+
`PROFILE="$HOME/tmp/smoke-profile-$$"`.
|
|
116
|
+
2. Headed login: `loginWithProfile({ profileDir: PROFILE, url: 'https://example-site.test/login', isAuthenticated: (p) => p.url().includes('/home'), timeoutMs: 300_000 })`
|
|
117
|
+
— sign in manually, confirm the context closes after your predicate passes.
|
|
118
|
+
3. Headless reuse: run the `withProfilePage` example above with
|
|
119
|
+
`headless: true` and confirm the callback sees the authenticated page.
|
|
120
|
+
4. Failure cases: re-run headless with a fresh non-existent `PROFILE`
|
|
121
|
+
(expect `BrowserProfileMissingError`); open the same profile in a second
|
|
122
|
+
browser and re-run (expect `BrowserProfileBusyError`); delete cookies via
|
|
123
|
+
the site and re-run with the predicate (expect `LoginTimeoutError`).
|
|
124
|
+
|
|
125
|
+
Use a throwaway account. Do not run the headed-login step unattended or in CI.
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed failure surface for @gobing-ai/ts-browser-automation (task 0088).
|
|
3
|
+
* Each failure mode named in the task (missing/busy profile, auth expiry,
|
|
4
|
+
* timeout, cancellation, missing Playwright) maps to one error class so
|
|
5
|
+
* callers can branch without string matching.
|
|
6
|
+
*/
|
|
7
|
+
/** Base class for every error this package throws. */
|
|
8
|
+
export declare class BrowserAutomationError extends Error {
|
|
9
|
+
constructor(message: string, options?: {
|
|
10
|
+
cause?: unknown;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/** `profileDir` was empty or blank. */
|
|
14
|
+
export declare class InvalidProfileDirError extends BrowserAutomationError {
|
|
15
|
+
constructor(message?: string);
|
|
16
|
+
}
|
|
17
|
+
/** A headless run targeted a profile directory that does not exist. */
|
|
18
|
+
export declare class BrowserProfileMissingError extends BrowserAutomationError {
|
|
19
|
+
constructor(profileDir: string);
|
|
20
|
+
}
|
|
21
|
+
/** The profile directory is locked by another Chromium instance (singleton rule). */
|
|
22
|
+
export declare class BrowserProfileBusyError extends BrowserAutomationError {
|
|
23
|
+
constructor(profileDir: string, cause: unknown);
|
|
24
|
+
}
|
|
25
|
+
/** The `playwright` peer dependency or its Chromium browser is not installed. */
|
|
26
|
+
export declare class PlaywrightUnavailableError extends BrowserAutomationError {
|
|
27
|
+
constructor(message: string, options?: {
|
|
28
|
+
cause?: unknown;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The caller-owned readiness predicate never passed within the deadline.
|
|
33
|
+
* Recoverable: the message carries the re-login instruction.
|
|
34
|
+
*/
|
|
35
|
+
export declare class LoginTimeoutError extends BrowserAutomationError {
|
|
36
|
+
constructor(message: string);
|
|
37
|
+
}
|
|
38
|
+
/** The caller aborted the operation via `AbortSignal`. */
|
|
39
|
+
export declare class OperationAbortedError extends BrowserAutomationError {
|
|
40
|
+
constructor(message?: string);
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,sDAAsD;AACtD,qBAAa,sBAAuB,SAAQ,KAAK;gBACjC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAI7D;AAED,uCAAuC;AACvC,qBAAa,sBAAuB,SAAQ,sBAAsB;gBAClD,OAAO,SAAkF;CAIxG;AAED,uEAAuE;AACvE,qBAAa,0BAA2B,SAAQ,sBAAsB;gBACtD,UAAU,EAAE,MAAM;CAOjC;AAED,qFAAqF;AACrF,qBAAa,uBAAwB,SAAQ,sBAAsB;gBACnD,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO;CAQjD;AAED,iFAAiF;AACjF,qBAAa,0BAA2B,SAAQ,sBAAsB;gBACtD,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAI7D;AAED;;;GAGG;AACH,qBAAa,iBAAkB,SAAQ,sBAAsB;gBAC7C,OAAO,EAAE,MAAM;CAI9B;AAED,0DAA0D;AAC1D,qBAAa,qBAAsB,SAAQ,sBAAsB;gBACjD,OAAO,SAAgE;CAItF"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed failure surface for @gobing-ai/ts-browser-automation (task 0088).
|
|
3
|
+
* Each failure mode named in the task (missing/busy profile, auth expiry,
|
|
4
|
+
* timeout, cancellation, missing Playwright) maps to one error class so
|
|
5
|
+
* callers can branch without string matching.
|
|
6
|
+
*/
|
|
7
|
+
/** Base class for every error this package throws. */
|
|
8
|
+
export class BrowserAutomationError extends Error {
|
|
9
|
+
constructor(message, options) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.name = 'BrowserAutomationError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** `profileDir` was empty or blank. */
|
|
15
|
+
export class InvalidProfileDirError extends BrowserAutomationError {
|
|
16
|
+
constructor(message = 'profileDir must be a non-empty path to a dedicated browser profile directory.') {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'InvalidProfileDirError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** A headless run targeted a profile directory that does not exist. */
|
|
22
|
+
export class BrowserProfileMissingError extends BrowserAutomationError {
|
|
23
|
+
constructor(profileDir) {
|
|
24
|
+
super(`Profile directory does not exist: "${profileDir}". Headless runs never create a profile — ` +
|
|
25
|
+
'run loginWithProfile() headed once to create and authenticate it.');
|
|
26
|
+
this.name = 'BrowserProfileMissingError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** The profile directory is locked by another Chromium instance (singleton rule). */
|
|
30
|
+
export class BrowserProfileBusyError extends BrowserAutomationError {
|
|
31
|
+
constructor(profileDir, cause) {
|
|
32
|
+
super(`Profile directory is already in use by another browser instance: "${profileDir}". ` +
|
|
33
|
+
'Close the other instance or use a different profileDir — a profile must never be shared by concurrent browsers.', { cause });
|
|
34
|
+
this.name = 'BrowserProfileBusyError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** The `playwright` peer dependency or its Chromium browser is not installed. */
|
|
38
|
+
export class PlaywrightUnavailableError extends BrowserAutomationError {
|
|
39
|
+
constructor(message, options) {
|
|
40
|
+
super(message, options);
|
|
41
|
+
this.name = 'PlaywrightUnavailableError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The caller-owned readiness predicate never passed within the deadline.
|
|
46
|
+
* Recoverable: the message carries the re-login instruction.
|
|
47
|
+
*/
|
|
48
|
+
export class LoginTimeoutError extends BrowserAutomationError {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'LoginTimeoutError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** The caller aborted the operation via `AbortSignal`. */
|
|
55
|
+
export class OperationAbortedError extends BrowserAutomationError {
|
|
56
|
+
constructor(message = 'Browser automation operation was cancelled via AbortSignal.') {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = 'OperationAbortedError';
|
|
59
|
+
}
|
|
60
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { BrowserAutomationError, BrowserProfileBusyError, BrowserProfileMissingError, InvalidProfileDirError, LoginTimeoutError, OperationAbortedError, PlaywrightUnavailableError, } from './errors';
|
|
2
|
+
export type { BrowserLauncher, PersistentLauncherOptions, ProfileLaunchRequest } from './launcher';
|
|
3
|
+
export type { IsAuthenticatedPredicate, LoginWithProfileOptions, OpenPersistentBrowserOptions, ProfilePageHandle, WithProfilePageOptions, } from './operations';
|
|
4
|
+
export { loginWithProfile, openPersistentBrowser, withProfilePage } from './operations';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,sBAAsB,EACtB,uBAAuB,EACvB,0BAA0B,EAC1B,sBAAsB,EACtB,iBAAiB,EACjB,qBAAqB,EACrB,0BAA0B,GAC7B,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,eAAe,EAAE,yBAAyB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AACnG,YAAY,EACR,wBAAwB,EACxB,uBAAuB,EACvB,4BAA4B,EAC5B,iBAAiB,EACjB,sBAAsB,GACzB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { BrowserAutomationError, BrowserProfileBusyError, BrowserProfileMissingError, InvalidProfileDirError, LoginTimeoutError, OperationAbortedError, PlaywrightUnavailableError, } from './errors.js';
|
|
2
|
+
export { loginWithProfile, openPersistentBrowser, withProfilePage } from './operations.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type FileSystem } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import type { BrowserContext } from 'playwright';
|
|
3
|
+
/** Request for one persistent-context launch. `profileDir` is resolved to an absolute path before launch. */
|
|
4
|
+
export interface ProfileLaunchRequest {
|
|
5
|
+
profileDir: string;
|
|
6
|
+
headless: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Seam for opening a persistent Chromium context. The package's default
|
|
10
|
+
* implementation is {@link createPersistentLauncher}; tests and consumers may
|
|
11
|
+
* inject their own to fake or customise browser provisioning.
|
|
12
|
+
*/
|
|
13
|
+
export interface BrowserLauncher {
|
|
14
|
+
launch(request: ProfileLaunchRequest): Promise<BrowserContext>;
|
|
15
|
+
}
|
|
16
|
+
/** Options for the internal persistent launcher; all fields optional with safe defaults. */
|
|
17
|
+
export interface PersistentLauncherOptions {
|
|
18
|
+
/** Playwright loader override (tests inject a fake; the default lazily imports the peer). */
|
|
19
|
+
loadPlaywright?: () => Promise<typeof import('playwright')>;
|
|
20
|
+
/** Filesystem for profile-directory existence checks (default: ts-runtime node/bun). */
|
|
21
|
+
fileSystem?: FileSystem;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Create the profile directory with owner-only permissions when it does not
|
|
25
|
+
* exist. A headless run never creates one: an absent profile means no
|
|
26
|
+
* authenticated session exists, and silently launching a fresh one would
|
|
27
|
+
* fake a login (task 0088 R4).
|
|
28
|
+
*/
|
|
29
|
+
export declare function ensureProfileDir(profileDir: string, headless: boolean, fs: FileSystem): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* @internal Test-visible seam: the real runtime-lazy `import('playwright')`.
|
|
32
|
+
* Exported only so the coverage test can execute it without launching a
|
|
33
|
+
* browser — every default-launcher path that reaches it would call the real
|
|
34
|
+
* `launchPersistentContext` next.
|
|
35
|
+
*/
|
|
36
|
+
export declare function defaultLoadPlaywright(): Promise<typeof import('playwright')>;
|
|
37
|
+
/**
|
|
38
|
+
* Default {@link BrowserLauncher}: lazily imports the `playwright` peer,
|
|
39
|
+
* guarantees an owner-only profile directory, and maps known Chromium/Playwright
|
|
40
|
+
* launch failures onto this package's typed errors.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createPersistentLauncher(options?: PersistentLauncherOptions): BrowserLauncher;
|
|
43
|
+
//# sourceMappingURL=launcher.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"launcher.d.ts","sourceRoot":"","sources":["../src/launcher.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,KAAK,UAAU,EAAkB,MAAM,uBAAuB,CAAC;AACxE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAkBjD,6GAA6G;AAC7G,MAAM,WAAW,oBAAoB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;CACrB;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC5B,MAAM,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CAClE;AAED,4FAA4F;AAC5F,MAAM,WAAW,yBAAyB;IACtC,6FAA6F;IAC7F,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC,cAAc,YAAY,CAAC,CAAC,CAAC;IAC5D,wFAAwF;IACxF,UAAU,CAAC,EAAE,UAAU,CAAC;CAC3B;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAS3G;AAED;;;;;GAKG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,cAAc,YAAY,CAAC,CAAC,CAElF;AAcD;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,GAAE,yBAA8B,GAAG,eAAe,CAyBjG"}
|
package/dist/launcher.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser adapter — the single sanctioned exception to the ts-runtime
|
|
3
|
+
* platform-API ownership rule (ADR-032): the Playwright runtime import and
|
|
4
|
+
* the owner-only (0o700) creation of the profile directory live in this
|
|
5
|
+
* module and nowhere else. Generic path math and existence checks route
|
|
6
|
+
* through @gobing-ai/ts-runtime. No browser side effect happens at import.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir } from 'node:fs/promises';
|
|
9
|
+
import { nodeBunFactory } from '@gobing-ai/ts-runtime';
|
|
10
|
+
import { BrowserProfileBusyError, BrowserProfileMissingError, InvalidProfileDirError, PlaywrightUnavailableError, } from './errors.js';
|
|
11
|
+
import { resolveProfileDir } from './profile-dir.js';
|
|
12
|
+
/** Owner-only access for a newly created profile directory (existing directories are adopted in place). */
|
|
13
|
+
const PROFILE_DIR_MODE = 0o700;
|
|
14
|
+
/** Chromium singleton/lock failure signatures surfaced by Playwright when a profile is already open. */
|
|
15
|
+
const BUSY_PROFILE_PATTERN = /singleton|already in use|in use by another/i;
|
|
16
|
+
/** Playwright's "browser executable missing" signature. */
|
|
17
|
+
const CHROMIUM_MISSING_PATTERN = /playwright install|executable doesn't exist/i;
|
|
18
|
+
/**
|
|
19
|
+
* Create the profile directory with owner-only permissions when it does not
|
|
20
|
+
* exist. A headless run never creates one: an absent profile means no
|
|
21
|
+
* authenticated session exists, and silently launching a fresh one would
|
|
22
|
+
* fake a login (task 0088 R4).
|
|
23
|
+
*/
|
|
24
|
+
export async function ensureProfileDir(profileDir, headless, fs) {
|
|
25
|
+
const existing = await fs.stat(profileDir);
|
|
26
|
+
if (existing) {
|
|
27
|
+
if (!existing.isDirectory())
|
|
28
|
+
throw new InvalidProfileDirError(`profileDir is not a directory: "${profileDir}".`);
|
|
29
|
+
return; // adopt the existing directory in place — never move, re-permission, or delete
|
|
30
|
+
}
|
|
31
|
+
if (headless)
|
|
32
|
+
throw new BrowserProfileMissingError(profileDir);
|
|
33
|
+
await mkdir(profileDir, { recursive: true, mode: PROFILE_DIR_MODE });
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* @internal Test-visible seam: the real runtime-lazy `import('playwright')`.
|
|
37
|
+
* Exported only so the coverage test can execute it without launching a
|
|
38
|
+
* browser — every default-launcher path that reaches it would call the real
|
|
39
|
+
* `launchPersistentContext` next.
|
|
40
|
+
*/
|
|
41
|
+
export async function defaultLoadPlaywright() {
|
|
42
|
+
return import('playwright');
|
|
43
|
+
}
|
|
44
|
+
function mapLaunchError(profileDir, error) {
|
|
45
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
46
|
+
if (BUSY_PROFILE_PATTERN.test(message))
|
|
47
|
+
return new BrowserProfileBusyError(profileDir, error);
|
|
48
|
+
if (CHROMIUM_MISSING_PATTERN.test(message)) {
|
|
49
|
+
return new PlaywrightUnavailableError('Chromium is not installed for Playwright. Run `npx playwright install chromium` and retry.', { cause: error });
|
|
50
|
+
}
|
|
51
|
+
return error;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Default {@link BrowserLauncher}: lazily imports the `playwright` peer,
|
|
55
|
+
* guarantees an owner-only profile directory, and maps known Chromium/Playwright
|
|
56
|
+
* launch failures onto this package's typed errors.
|
|
57
|
+
*/
|
|
58
|
+
export function createPersistentLauncher(options = {}) {
|
|
59
|
+
const loadPlaywright = options.loadPlaywright ?? defaultLoadPlaywright;
|
|
60
|
+
const fs = options.fileSystem ?? nodeBunFactory.createFileSystem();
|
|
61
|
+
return {
|
|
62
|
+
async launch(request) {
|
|
63
|
+
const profileDir = resolveProfileDir(request.profileDir);
|
|
64
|
+
await ensureProfileDir(profileDir, request.headless, fs);
|
|
65
|
+
let playwright;
|
|
66
|
+
try {
|
|
67
|
+
playwright = await loadPlaywright();
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof PlaywrightUnavailableError)
|
|
71
|
+
throw error;
|
|
72
|
+
throw new PlaywrightUnavailableError('The "playwright" peer dependency is not installed. Add playwright@^1.55.0 to your dependencies ' +
|
|
73
|
+
'and run `npx playwright install chromium` to download the browser.', { cause: error });
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return await playwright.chromium.launchPersistentContext(profileDir, { headless: request.headless });
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
throw mapLaunchError(profileDir, error);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { BrowserContext, Page } from 'playwright';
|
|
2
|
+
import { type BrowserLauncher } from './launcher';
|
|
3
|
+
/** Caller-owned readiness check. The package never infers authentication from profile state. */
|
|
4
|
+
export type IsAuthenticatedPredicate = (page: Page) => boolean | Promise<boolean>;
|
|
5
|
+
/** Options for the low-level persistent-context opener with caller-owned lifetime. */
|
|
6
|
+
export interface OpenPersistentBrowserOptions {
|
|
7
|
+
/** Dedicated Chromium user-data directory (created owner-only when absent; adopted in place when present). */
|
|
8
|
+
profileDir: string;
|
|
9
|
+
headless: boolean;
|
|
10
|
+
/** Launcher override (tests / custom browser provisioning). */
|
|
11
|
+
launcher?: BrowserLauncher;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Low-level persistent-context opener. The returned context's lifetime is
|
|
15
|
+
* caller-owned — close it in a `finally` block, or prefer {@link withProfilePage}.
|
|
16
|
+
*/
|
|
17
|
+
export declare function openPersistentBrowser(options: OpenPersistentBrowserOptions): Promise<BrowserContext>;
|
|
18
|
+
/** Options for the headed one-time login that polls the caller's readiness predicate. */
|
|
19
|
+
export interface LoginWithProfileOptions {
|
|
20
|
+
/** Dedicated Chromium user-data directory. */
|
|
21
|
+
profileDir: string;
|
|
22
|
+
/** Login page to open in the visible browser. */
|
|
23
|
+
url: string;
|
|
24
|
+
/** Site-owned readiness check polled until it passes or the deadline expires. */
|
|
25
|
+
isAuthenticated: IsAuthenticatedPredicate;
|
|
26
|
+
/** Finite deadline for predicate confirmation. */
|
|
27
|
+
timeoutMs: number;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
launcher?: BrowserLauncher;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Open a headed browser on `url`, let the user sign in, and resolve only once
|
|
33
|
+
* `isAuthenticated(page)` passes. The context is closed on success, timeout,
|
|
34
|
+
* cancellation, and error. No credential is ever read or stored — the profile
|
|
35
|
+
* directory itself is the login artifact.
|
|
36
|
+
*/
|
|
37
|
+
export declare function loginWithProfile(options: LoginWithProfileOptions): Promise<void>;
|
|
38
|
+
/** Handle returned to the callback with the opened context and its page. */
|
|
39
|
+
export interface ProfilePageHandle {
|
|
40
|
+
context: BrowserContext;
|
|
41
|
+
page: Page;
|
|
42
|
+
}
|
|
43
|
+
/** Options for the scoped callback helper that guarantees context closure in `finally`. */
|
|
44
|
+
export interface WithProfilePageOptions {
|
|
45
|
+
/** Dedicated Chromium user-data directory. */
|
|
46
|
+
profileDir: string;
|
|
47
|
+
/** Headless reuse defaults to `false`; pass `true` for unattended runs (requires an existing profile). */
|
|
48
|
+
headless?: boolean;
|
|
49
|
+
url?: string;
|
|
50
|
+
/** When provided, the callback runs only after the readiness predicate passes (task 0088 R3). */
|
|
51
|
+
isAuthenticated?: IsAuthenticatedPredicate;
|
|
52
|
+
/** Deadline for predicate confirmation (default 30s; ignored without `isAuthenticated`). */
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
launcher?: BrowserLauncher;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Scoped session helper: launch a persistent context, hand the caller
|
|
59
|
+
* `{ context, page }`, and guarantee `context.close()` in `finally` on every
|
|
60
|
+
* exit path. `context` is a genuine Playwright context — callers own
|
|
61
|
+
* site-specific CDP via `context.newCDPSession(page)`.
|
|
62
|
+
*/
|
|
63
|
+
export declare function withProfilePage<T>(options: WithProfilePageOptions, callback: (handle: ProfilePageHandle) => Promise<T>): Promise<T>;
|
|
64
|
+
//# sourceMappingURL=operations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"operations.d.ts","sourceRoot":"","sources":["../src/operations.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAEvD,OAAO,EAAE,KAAK,eAAe,EAA4B,MAAM,YAAY,CAAC;AAa5E,gGAAgG;AAChG,MAAM,MAAM,wBAAwB,GAAG,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAElF,sFAAsF;AACtF,MAAM,WAAW,4BAA4B;IACzC,8GAA8G;IAC9G,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,eAAe,CAAC;CAC9B;AAED;;;GAGG;AACH,wBAAsB,qBAAqB,CAAC,OAAO,EAAE,4BAA4B,GAAG,OAAO,CAAC,cAAc,CAAC,CAK1G;AAED,yFAAyF;AACzF,MAAM,WAAW,uBAAuB;IACpC,8CAA8C;IAC9C,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,GAAG,EAAE,MAAM,CAAC;IACZ,iFAAiF;IACjF,eAAe,EAAE,wBAAwB,CAAC;IAC1C,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,CAAC,EAAE,eAAe,CAAC;CAC9B;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CActF;AAED,4EAA4E;AAC5E,MAAM,WAAW,iBAAiB;IAC9B,OAAO,EAAE,cAAc,CAAC;IACxB,IAAI,EAAE,IAAI,CAAC;CACd;AAED,2FAA2F;AAC3F,MAAM,WAAW,sBAAsB;IACnC,8CAA8C;IAC9C,UAAU,EAAE,MAAM,CAAC;IACnB,0GAA0G;IAC1G,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,iGAAiG;IACjG,eAAe,CAAC,EAAE,wBAAwB,CAAC;IAC3C,4FAA4F;IAC5F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,CAAC,EAAE,eAAe,CAAC;CAC9B;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,CAAC,EACnC,OAAO,EAAE,sBAAsB,EAC/B,QAAQ,EAAE,CAAC,MAAM,EAAE,iBAAiB,KAAK,OAAO,CAAC,CAAC,CAAC,GACpD,OAAO,CAAC,CAAC,CAAC,CAiBZ"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { LoginTimeoutError, OperationAbortedError } from './errors.js';
|
|
2
|
+
import { createPersistentLauncher } from './launcher.js';
|
|
3
|
+
import { resolveProfileDir } from './profile-dir.js';
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
|
+
const POLL_INTERVAL_MS = 250;
|
|
6
|
+
// Lazily built so importing the module has no side effects (sideEffects: false).
|
|
7
|
+
let defaultLauncher;
|
|
8
|
+
function getDefaultLauncher() {
|
|
9
|
+
if (defaultLauncher === undefined)
|
|
10
|
+
defaultLauncher = createPersistentLauncher();
|
|
11
|
+
return defaultLauncher;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Low-level persistent-context opener. The returned context's lifetime is
|
|
15
|
+
* caller-owned — close it in a `finally` block, or prefer {@link withProfilePage}.
|
|
16
|
+
*/
|
|
17
|
+
export async function openPersistentBrowser(options) {
|
|
18
|
+
return (options.launcher ?? getDefaultLauncher()).launch({
|
|
19
|
+
profileDir: resolveProfileDir(options.profileDir),
|
|
20
|
+
headless: options.headless,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Open a headed browser on `url`, let the user sign in, and resolve only once
|
|
25
|
+
* `isAuthenticated(page)` passes. The context is closed on success, timeout,
|
|
26
|
+
* cancellation, and error. No credential is ever read or stored — the profile
|
|
27
|
+
* directory itself is the login artifact.
|
|
28
|
+
*/
|
|
29
|
+
export async function loginWithProfile(options) {
|
|
30
|
+
assertNotAborted(options.signal);
|
|
31
|
+
assertFiniteTimeoutMs(options.timeoutMs);
|
|
32
|
+
const context = await (options.launcher ?? getDefaultLauncher()).launch({
|
|
33
|
+
profileDir: resolveProfileDir(options.profileDir),
|
|
34
|
+
headless: false,
|
|
35
|
+
});
|
|
36
|
+
try {
|
|
37
|
+
const page = await openPage(context);
|
|
38
|
+
await page.goto(options.url);
|
|
39
|
+
await pollPredicate(page, options.isAuthenticated, options.timeoutMs, options.signal);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
await context.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Scoped session helper: launch a persistent context, hand the caller
|
|
47
|
+
* `{ context, page }`, and guarantee `context.close()` in `finally` on every
|
|
48
|
+
* exit path. `context` is a genuine Playwright context — callers own
|
|
49
|
+
* site-specific CDP via `context.newCDPSession(page)`.
|
|
50
|
+
*/
|
|
51
|
+
export async function withProfilePage(options, callback) {
|
|
52
|
+
assertNotAborted(options.signal);
|
|
53
|
+
if (options.isAuthenticated !== undefined)
|
|
54
|
+
assertFiniteTimeoutMs(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
55
|
+
const context = await (options.launcher ?? getDefaultLauncher()).launch({
|
|
56
|
+
profileDir: resolveProfileDir(options.profileDir),
|
|
57
|
+
headless: options.headless ?? false,
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
const page = await openPage(context);
|
|
61
|
+
if (options.url !== undefined)
|
|
62
|
+
await page.goto(options.url);
|
|
63
|
+
if (options.isAuthenticated !== undefined) {
|
|
64
|
+
await pollPredicate(page, options.isAuthenticated, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.signal);
|
|
65
|
+
}
|
|
66
|
+
return await callback({ context, page });
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await context.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function assertNotAborted(signal) {
|
|
73
|
+
if (signal?.aborted)
|
|
74
|
+
throw new OperationAbortedError();
|
|
75
|
+
}
|
|
76
|
+
async function openPage(context) {
|
|
77
|
+
const existing = context.pages()[0];
|
|
78
|
+
return existing ?? (await context.newPage());
|
|
79
|
+
}
|
|
80
|
+
/** Rejects a non-finite or negative timeout at op entry — before any browser opens — so a bad value cannot park a headed window in an endless poll. */
|
|
81
|
+
function assertFiniteTimeoutMs(timeoutMs) {
|
|
82
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
83
|
+
throw new TypeError(`timeoutMs must be a finite non-negative number of milliseconds; received ${timeoutMs}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function pollPredicate(page, predicate, timeoutMs, signal) {
|
|
87
|
+
const timeoutError = new LoginTimeoutError(`Authentication was not confirmed within ${timeoutMs}ms. The profile's site session has likely ` +
|
|
88
|
+
'expired or the sign-in was not completed: re-run loginWithProfile() with the same profileDir to sign in again.');
|
|
89
|
+
let timer;
|
|
90
|
+
let onAbort;
|
|
91
|
+
let stopped = false;
|
|
92
|
+
const deadline = new Promise((_, reject) => {
|
|
93
|
+
timer = setTimeout(() => reject(timeoutError), timeoutMs);
|
|
94
|
+
if (signal) {
|
|
95
|
+
onAbort = () => reject(new OperationAbortedError());
|
|
96
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
97
|
+
if (signal.aborted)
|
|
98
|
+
onAbort();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
const poll = async () => {
|
|
102
|
+
for (;;) {
|
|
103
|
+
if (stopped)
|
|
104
|
+
return;
|
|
105
|
+
assertNotAborted(signal);
|
|
106
|
+
if (await predicate(page))
|
|
107
|
+
return;
|
|
108
|
+
await sleep(POLL_INTERVAL_MS);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
await Promise.race([poll(), deadline]);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
stopped = true;
|
|
116
|
+
if (timer)
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
if (signal && onAbort)
|
|
119
|
+
signal.removeEventListener('abort', onAbort);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function sleep(ms) {
|
|
123
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
124
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate a caller-supplied `profileDir` and resolve it to an absolute path
|
|
3
|
+
* (relative inputs resolve against the current working directory). Rejection
|
|
4
|
+
* of empty/blank paths happens here so every operation and launcher entry
|
|
5
|
+
* point behaves identically.
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveProfileDir(profileDir: string): string;
|
|
8
|
+
//# sourceMappingURL=profile-dir.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"profile-dir.d.ts","sourceRoot":"","sources":["../src/profile-dir.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAS5D"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { isAbsolutePath, resolvePath } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import { InvalidProfileDirError } from './errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* Validate a caller-supplied `profileDir` and resolve it to an absolute path
|
|
5
|
+
* (relative inputs resolve against the current working directory). Rejection
|
|
6
|
+
* of empty/blank paths happens here so every operation and launcher entry
|
|
7
|
+
* point behaves identically.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveProfileDir(profileDir) {
|
|
10
|
+
if (profileDir.trim() === '') {
|
|
11
|
+
throw new InvalidProfileDirError();
|
|
12
|
+
}
|
|
13
|
+
const resolved = resolvePath(profileDir);
|
|
14
|
+
if (!isAbsolutePath(resolved)) {
|
|
15
|
+
throw new InvalidProfileDirError(`profileDir could not be resolved to an absolute path: "${profileDir}".`);
|
|
16
|
+
}
|
|
17
|
+
return resolved;
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gobing-ai/ts-browser-automation",
|
|
3
|
+
"version": "0.5.10",
|
|
4
|
+
"description": "@gobing-ai/ts-browser-automation — Reusable Playwright persistent browser profile sessions: headed login, headless reuse.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"typescript",
|
|
7
|
+
"browser",
|
|
8
|
+
"playwright",
|
|
9
|
+
"chromium",
|
|
10
|
+
"automation",
|
|
11
|
+
"session"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/gobing-ai/ts-libs.git",
|
|
16
|
+
"directory": "packages/browser-automation"
|
|
17
|
+
},
|
|
18
|
+
"author": "Robin Min <minlongbing@gmail.com>",
|
|
19
|
+
"contributors": [
|
|
20
|
+
"Robin Min <minlongbing@gmail.com>"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"private": false,
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src",
|
|
37
|
+
"README.md"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.build.json && bun ../../scripts/builder.ts fix-dist-esm-extensions dist",
|
|
41
|
+
"test": "NODE_ENV=test bun test --coverage --coverage-dir=.coverage --reporter=dots",
|
|
42
|
+
"test:full": "NODE_ENV=test bun test --update-snapshots --coverage --coverage-dir=.coverage",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"lint": "biome check . && bun run typecheck",
|
|
45
|
+
"format": "biome check . --write",
|
|
46
|
+
"check": "bun run lint && bun run test",
|
|
47
|
+
"prepublishOnly": "bun run build",
|
|
48
|
+
"release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-browser-automation-v<version> && git push --tags' && exit 1"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@gobing-ai/ts-runtime": "workspace:*"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"playwright": "^1.55.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/bun": "1.3.14",
|
|
58
|
+
"playwright": "1.55.0"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed failure surface for @gobing-ai/ts-browser-automation (task 0088).
|
|
3
|
+
* Each failure mode named in the task (missing/busy profile, auth expiry,
|
|
4
|
+
* timeout, cancellation, missing Playwright) maps to one error class so
|
|
5
|
+
* callers can branch without string matching.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Base class for every error this package throws. */
|
|
9
|
+
export class BrowserAutomationError extends Error {
|
|
10
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
11
|
+
super(message, options);
|
|
12
|
+
this.name = 'BrowserAutomationError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `profileDir` was empty or blank. */
|
|
17
|
+
export class InvalidProfileDirError extends BrowserAutomationError {
|
|
18
|
+
constructor(message = 'profileDir must be a non-empty path to a dedicated browser profile directory.') {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'InvalidProfileDirError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A headless run targeted a profile directory that does not exist. */
|
|
25
|
+
export class BrowserProfileMissingError extends BrowserAutomationError {
|
|
26
|
+
constructor(profileDir: string) {
|
|
27
|
+
super(
|
|
28
|
+
`Profile directory does not exist: "${profileDir}". Headless runs never create a profile — ` +
|
|
29
|
+
'run loginWithProfile() headed once to create and authenticate it.',
|
|
30
|
+
);
|
|
31
|
+
this.name = 'BrowserProfileMissingError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The profile directory is locked by another Chromium instance (singleton rule). */
|
|
36
|
+
export class BrowserProfileBusyError extends BrowserAutomationError {
|
|
37
|
+
constructor(profileDir: string, cause: unknown) {
|
|
38
|
+
super(
|
|
39
|
+
`Profile directory is already in use by another browser instance: "${profileDir}". ` +
|
|
40
|
+
'Close the other instance or use a different profileDir — a profile must never be shared by concurrent browsers.',
|
|
41
|
+
{ cause },
|
|
42
|
+
);
|
|
43
|
+
this.name = 'BrowserProfileBusyError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The `playwright` peer dependency or its Chromium browser is not installed. */
|
|
48
|
+
export class PlaywrightUnavailableError extends BrowserAutomationError {
|
|
49
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
50
|
+
super(message, options);
|
|
51
|
+
this.name = 'PlaywrightUnavailableError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The caller-owned readiness predicate never passed within the deadline.
|
|
57
|
+
* Recoverable: the message carries the re-login instruction.
|
|
58
|
+
*/
|
|
59
|
+
export class LoginTimeoutError extends BrowserAutomationError {
|
|
60
|
+
constructor(message: string) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = 'LoginTimeoutError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The caller aborted the operation via `AbortSignal`. */
|
|
67
|
+
export class OperationAbortedError extends BrowserAutomationError {
|
|
68
|
+
constructor(message = 'Browser automation operation was cancelled via AbortSignal.') {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = 'OperationAbortedError';
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export {
|
|
2
|
+
BrowserAutomationError,
|
|
3
|
+
BrowserProfileBusyError,
|
|
4
|
+
BrowserProfileMissingError,
|
|
5
|
+
InvalidProfileDirError,
|
|
6
|
+
LoginTimeoutError,
|
|
7
|
+
OperationAbortedError,
|
|
8
|
+
PlaywrightUnavailableError,
|
|
9
|
+
} from './errors';
|
|
10
|
+
export type { BrowserLauncher, PersistentLauncherOptions, ProfileLaunchRequest } from './launcher';
|
|
11
|
+
export type {
|
|
12
|
+
IsAuthenticatedPredicate,
|
|
13
|
+
LoginWithProfileOptions,
|
|
14
|
+
OpenPersistentBrowserOptions,
|
|
15
|
+
ProfilePageHandle,
|
|
16
|
+
WithProfilePageOptions,
|
|
17
|
+
} from './operations';
|
|
18
|
+
export { loginWithProfile, openPersistentBrowser, withProfilePage } from './operations';
|
package/src/launcher.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser adapter — the single sanctioned exception to the ts-runtime
|
|
3
|
+
* platform-API ownership rule (ADR-032): the Playwright runtime import and
|
|
4
|
+
* the owner-only (0o700) creation of the profile directory live in this
|
|
5
|
+
* module and nowhere else. Generic path math and existence checks route
|
|
6
|
+
* through @gobing-ai/ts-runtime. No browser side effect happens at import.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir } from 'node:fs/promises';
|
|
9
|
+
import { type FileSystem, nodeBunFactory } from '@gobing-ai/ts-runtime';
|
|
10
|
+
import type { BrowserContext } from 'playwright';
|
|
11
|
+
import {
|
|
12
|
+
BrowserProfileBusyError,
|
|
13
|
+
BrowserProfileMissingError,
|
|
14
|
+
InvalidProfileDirError,
|
|
15
|
+
PlaywrightUnavailableError,
|
|
16
|
+
} from './errors';
|
|
17
|
+
import { resolveProfileDir } from './profile-dir';
|
|
18
|
+
|
|
19
|
+
/** Owner-only access for a newly created profile directory (existing directories are adopted in place). */
|
|
20
|
+
const PROFILE_DIR_MODE = 0o700;
|
|
21
|
+
|
|
22
|
+
/** Chromium singleton/lock failure signatures surfaced by Playwright when a profile is already open. */
|
|
23
|
+
const BUSY_PROFILE_PATTERN = /singleton|already in use|in use by another/i;
|
|
24
|
+
|
|
25
|
+
/** Playwright's "browser executable missing" signature. */
|
|
26
|
+
const CHROMIUM_MISSING_PATTERN = /playwright install|executable doesn't exist/i;
|
|
27
|
+
|
|
28
|
+
/** Request for one persistent-context launch. `profileDir` is resolved to an absolute path before launch. */
|
|
29
|
+
export interface ProfileLaunchRequest {
|
|
30
|
+
profileDir: string;
|
|
31
|
+
headless: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Seam for opening a persistent Chromium context. The package's default
|
|
36
|
+
* implementation is {@link createPersistentLauncher}; tests and consumers may
|
|
37
|
+
* inject their own to fake or customise browser provisioning.
|
|
38
|
+
*/
|
|
39
|
+
export interface BrowserLauncher {
|
|
40
|
+
launch(request: ProfileLaunchRequest): Promise<BrowserContext>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Options for the internal persistent launcher; all fields optional with safe defaults. */
|
|
44
|
+
export interface PersistentLauncherOptions {
|
|
45
|
+
/** Playwright loader override (tests inject a fake; the default lazily imports the peer). */
|
|
46
|
+
loadPlaywright?: () => Promise<typeof import('playwright')>;
|
|
47
|
+
/** Filesystem for profile-directory existence checks (default: ts-runtime node/bun). */
|
|
48
|
+
fileSystem?: FileSystem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create the profile directory with owner-only permissions when it does not
|
|
53
|
+
* exist. A headless run never creates one: an absent profile means no
|
|
54
|
+
* authenticated session exists, and silently launching a fresh one would
|
|
55
|
+
* fake a login (task 0088 R4).
|
|
56
|
+
*/
|
|
57
|
+
export async function ensureProfileDir(profileDir: string, headless: boolean, fs: FileSystem): Promise<void> {
|
|
58
|
+
const existing = await fs.stat(profileDir);
|
|
59
|
+
if (existing) {
|
|
60
|
+
if (!existing.isDirectory())
|
|
61
|
+
throw new InvalidProfileDirError(`profileDir is not a directory: "${profileDir}".`);
|
|
62
|
+
return; // adopt the existing directory in place — never move, re-permission, or delete
|
|
63
|
+
}
|
|
64
|
+
if (headless) throw new BrowserProfileMissingError(profileDir);
|
|
65
|
+
await mkdir(profileDir, { recursive: true, mode: PROFILE_DIR_MODE });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @internal Test-visible seam: the real runtime-lazy `import('playwright')`.
|
|
70
|
+
* Exported only so the coverage test can execute it without launching a
|
|
71
|
+
* browser — every default-launcher path that reaches it would call the real
|
|
72
|
+
* `launchPersistentContext` next.
|
|
73
|
+
*/
|
|
74
|
+
export async function defaultLoadPlaywright(): Promise<typeof import('playwright')> {
|
|
75
|
+
return import('playwright');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function mapLaunchError(profileDir: string, error: unknown): unknown {
|
|
79
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
80
|
+
if (BUSY_PROFILE_PATTERN.test(message)) return new BrowserProfileBusyError(profileDir, error);
|
|
81
|
+
if (CHROMIUM_MISSING_PATTERN.test(message)) {
|
|
82
|
+
return new PlaywrightUnavailableError(
|
|
83
|
+
'Chromium is not installed for Playwright. Run `npx playwright install chromium` and retry.',
|
|
84
|
+
{ cause: error },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return error;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Default {@link BrowserLauncher}: lazily imports the `playwright` peer,
|
|
92
|
+
* guarantees an owner-only profile directory, and maps known Chromium/Playwright
|
|
93
|
+
* launch failures onto this package's typed errors.
|
|
94
|
+
*/
|
|
95
|
+
export function createPersistentLauncher(options: PersistentLauncherOptions = {}): BrowserLauncher {
|
|
96
|
+
const loadPlaywright = options.loadPlaywright ?? defaultLoadPlaywright;
|
|
97
|
+
const fs = options.fileSystem ?? nodeBunFactory.createFileSystem();
|
|
98
|
+
return {
|
|
99
|
+
async launch(request: ProfileLaunchRequest): Promise<BrowserContext> {
|
|
100
|
+
const profileDir = resolveProfileDir(request.profileDir);
|
|
101
|
+
await ensureProfileDir(profileDir, request.headless, fs);
|
|
102
|
+
let playwright: typeof import('playwright');
|
|
103
|
+
try {
|
|
104
|
+
playwright = await loadPlaywright();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error instanceof PlaywrightUnavailableError) throw error;
|
|
107
|
+
throw new PlaywrightUnavailableError(
|
|
108
|
+
'The "playwright" peer dependency is not installed. Add playwright@^1.55.0 to your dependencies ' +
|
|
109
|
+
'and run `npx playwright install chromium` to download the browser.',
|
|
110
|
+
{ cause: error },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
return await playwright.chromium.launchPersistentContext(profileDir, { headless: request.headless });
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw mapLaunchError(profileDir, error);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { BrowserContext, Page } from 'playwright';
|
|
2
|
+
import { LoginTimeoutError, OperationAbortedError } from './errors';
|
|
3
|
+
import { type BrowserLauncher, createPersistentLauncher } from './launcher';
|
|
4
|
+
import { resolveProfileDir } from './profile-dir';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
7
|
+
const POLL_INTERVAL_MS = 250;
|
|
8
|
+
|
|
9
|
+
// Lazily built so importing the module has no side effects (sideEffects: false).
|
|
10
|
+
let defaultLauncher: BrowserLauncher | undefined;
|
|
11
|
+
function getDefaultLauncher(): BrowserLauncher {
|
|
12
|
+
if (defaultLauncher === undefined) defaultLauncher = createPersistentLauncher();
|
|
13
|
+
return defaultLauncher;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Caller-owned readiness check. The package never infers authentication from profile state. */
|
|
17
|
+
export type IsAuthenticatedPredicate = (page: Page) => boolean | Promise<boolean>;
|
|
18
|
+
|
|
19
|
+
/** Options for the low-level persistent-context opener with caller-owned lifetime. */
|
|
20
|
+
export interface OpenPersistentBrowserOptions {
|
|
21
|
+
/** Dedicated Chromium user-data directory (created owner-only when absent; adopted in place when present). */
|
|
22
|
+
profileDir: string;
|
|
23
|
+
headless: boolean;
|
|
24
|
+
/** Launcher override (tests / custom browser provisioning). */
|
|
25
|
+
launcher?: BrowserLauncher;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Low-level persistent-context opener. The returned context's lifetime is
|
|
30
|
+
* caller-owned — close it in a `finally` block, or prefer {@link withProfilePage}.
|
|
31
|
+
*/
|
|
32
|
+
export async function openPersistentBrowser(options: OpenPersistentBrowserOptions): Promise<BrowserContext> {
|
|
33
|
+
return (options.launcher ?? getDefaultLauncher()).launch({
|
|
34
|
+
profileDir: resolveProfileDir(options.profileDir),
|
|
35
|
+
headless: options.headless,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Options for the headed one-time login that polls the caller's readiness predicate. */
|
|
40
|
+
export interface LoginWithProfileOptions {
|
|
41
|
+
/** Dedicated Chromium user-data directory. */
|
|
42
|
+
profileDir: string;
|
|
43
|
+
/** Login page to open in the visible browser. */
|
|
44
|
+
url: string;
|
|
45
|
+
/** Site-owned readiness check polled until it passes or the deadline expires. */
|
|
46
|
+
isAuthenticated: IsAuthenticatedPredicate;
|
|
47
|
+
/** Finite deadline for predicate confirmation. */
|
|
48
|
+
timeoutMs: number;
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
launcher?: BrowserLauncher;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Open a headed browser on `url`, let the user sign in, and resolve only once
|
|
55
|
+
* `isAuthenticated(page)` passes. The context is closed on success, timeout,
|
|
56
|
+
* cancellation, and error. No credential is ever read or stored — the profile
|
|
57
|
+
* directory itself is the login artifact.
|
|
58
|
+
*/
|
|
59
|
+
export async function loginWithProfile(options: LoginWithProfileOptions): Promise<void> {
|
|
60
|
+
assertNotAborted(options.signal);
|
|
61
|
+
assertFiniteTimeoutMs(options.timeoutMs);
|
|
62
|
+
const context = await (options.launcher ?? getDefaultLauncher()).launch({
|
|
63
|
+
profileDir: resolveProfileDir(options.profileDir),
|
|
64
|
+
headless: false,
|
|
65
|
+
});
|
|
66
|
+
try {
|
|
67
|
+
const page = await openPage(context);
|
|
68
|
+
await page.goto(options.url);
|
|
69
|
+
await pollPredicate(page, options.isAuthenticated, options.timeoutMs, options.signal);
|
|
70
|
+
} finally {
|
|
71
|
+
await context.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Handle returned to the callback with the opened context and its page. */
|
|
76
|
+
export interface ProfilePageHandle {
|
|
77
|
+
context: BrowserContext;
|
|
78
|
+
page: Page;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Options for the scoped callback helper that guarantees context closure in `finally`. */
|
|
82
|
+
export interface WithProfilePageOptions {
|
|
83
|
+
/** Dedicated Chromium user-data directory. */
|
|
84
|
+
profileDir: string;
|
|
85
|
+
/** Headless reuse defaults to `false`; pass `true` for unattended runs (requires an existing profile). */
|
|
86
|
+
headless?: boolean;
|
|
87
|
+
url?: string;
|
|
88
|
+
/** When provided, the callback runs only after the readiness predicate passes (task 0088 R3). */
|
|
89
|
+
isAuthenticated?: IsAuthenticatedPredicate;
|
|
90
|
+
/** Deadline for predicate confirmation (default 30s; ignored without `isAuthenticated`). */
|
|
91
|
+
timeoutMs?: number;
|
|
92
|
+
signal?: AbortSignal;
|
|
93
|
+
launcher?: BrowserLauncher;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Scoped session helper: launch a persistent context, hand the caller
|
|
98
|
+
* `{ context, page }`, and guarantee `context.close()` in `finally` on every
|
|
99
|
+
* exit path. `context` is a genuine Playwright context — callers own
|
|
100
|
+
* site-specific CDP via `context.newCDPSession(page)`.
|
|
101
|
+
*/
|
|
102
|
+
export async function withProfilePage<T>(
|
|
103
|
+
options: WithProfilePageOptions,
|
|
104
|
+
callback: (handle: ProfilePageHandle) => Promise<T>,
|
|
105
|
+
): Promise<T> {
|
|
106
|
+
assertNotAborted(options.signal);
|
|
107
|
+
if (options.isAuthenticated !== undefined) assertFiniteTimeoutMs(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
108
|
+
const context = await (options.launcher ?? getDefaultLauncher()).launch({
|
|
109
|
+
profileDir: resolveProfileDir(options.profileDir),
|
|
110
|
+
headless: options.headless ?? false,
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
const page = await openPage(context);
|
|
114
|
+
if (options.url !== undefined) await page.goto(options.url);
|
|
115
|
+
if (options.isAuthenticated !== undefined) {
|
|
116
|
+
await pollPredicate(page, options.isAuthenticated, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.signal);
|
|
117
|
+
}
|
|
118
|
+
return await callback({ context, page });
|
|
119
|
+
} finally {
|
|
120
|
+
await context.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function assertNotAborted(signal: AbortSignal | undefined): void {
|
|
125
|
+
if (signal?.aborted) throw new OperationAbortedError();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function openPage(context: BrowserContext): Promise<Page> {
|
|
129
|
+
const existing = context.pages()[0];
|
|
130
|
+
return existing ?? (await context.newPage());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Rejects a non-finite or negative timeout at op entry — before any browser opens — so a bad value cannot park a headed window in an endless poll. */
|
|
134
|
+
function assertFiniteTimeoutMs(timeoutMs: number): void {
|
|
135
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
136
|
+
throw new TypeError(`timeoutMs must be a finite non-negative number of milliseconds; received ${timeoutMs}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function pollPredicate(
|
|
141
|
+
page: Page,
|
|
142
|
+
predicate: IsAuthenticatedPredicate,
|
|
143
|
+
timeoutMs: number,
|
|
144
|
+
signal: AbortSignal | undefined,
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
const timeoutError = new LoginTimeoutError(
|
|
147
|
+
`Authentication was not confirmed within ${timeoutMs}ms. The profile's site session has likely ` +
|
|
148
|
+
'expired or the sign-in was not completed: re-run loginWithProfile() with the same profileDir to sign in again.',
|
|
149
|
+
);
|
|
150
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
151
|
+
let onAbort: (() => void) | undefined;
|
|
152
|
+
let stopped = false;
|
|
153
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
154
|
+
timer = setTimeout(() => reject(timeoutError), timeoutMs);
|
|
155
|
+
if (signal) {
|
|
156
|
+
onAbort = () => reject(new OperationAbortedError());
|
|
157
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
158
|
+
if (signal.aborted) onAbort();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
const poll = async () => {
|
|
162
|
+
for (;;) {
|
|
163
|
+
if (stopped) return;
|
|
164
|
+
assertNotAborted(signal);
|
|
165
|
+
if (await predicate(page)) return;
|
|
166
|
+
await sleep(POLL_INTERVAL_MS);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
await Promise.race([poll(), deadline]);
|
|
171
|
+
} finally {
|
|
172
|
+
stopped = true;
|
|
173
|
+
if (timer) clearTimeout(timer);
|
|
174
|
+
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function sleep(ms: number): Promise<void> {
|
|
179
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
180
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isAbsolutePath, resolvePath } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import { InvalidProfileDirError } from './errors';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate a caller-supplied `profileDir` and resolve it to an absolute path
|
|
6
|
+
* (relative inputs resolve against the current working directory). Rejection
|
|
7
|
+
* of empty/blank paths happens here so every operation and launcher entry
|
|
8
|
+
* point behaves identically.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveProfileDir(profileDir: string): string {
|
|
11
|
+
if (profileDir.trim() === '') {
|
|
12
|
+
throw new InvalidProfileDirError();
|
|
13
|
+
}
|
|
14
|
+
const resolved = resolvePath(profileDir);
|
|
15
|
+
if (!isAbsolutePath(resolved)) {
|
|
16
|
+
throw new InvalidProfileDirError(`profileDir could not be resolved to an absolute path: "${profileDir}".`);
|
|
17
|
+
}
|
|
18
|
+
return resolved;
|
|
19
|
+
}
|