@arraypress/extension-kit 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * A stand-in for Chrome's `LanguageModel` (the Prompt API), with its shape,
3
+ * for an extension's unit tests and its e2e build (whose hook puts it on the
4
+ * worker's or page's `globalThis`: Playwright's Chromium has no model). It
5
+ * acts out what a real Chrome does: a model still to download (with
6
+ * progress), one this computer can't run, a slow first session (the cold
7
+ * start), a slow answer, an answer cut short by its signal, and a failure.
8
+ * Moved here from Better Downloads' tests, so every extension that uses the
9
+ * model tests against the same one.
10
+ *
11
+ * Plain browser JavaScript with no Node imports, so a test build can copy it in.
12
+ *
13
+ * @module testing/fake-model
14
+ */
15
+
16
+ /** @import { LanguageModelOptions, LanguageModelSession, LanguageModelStatic, PromptOptions } from '../ai/model.js' */
17
+
18
+ /**
19
+ * How the fake behaves; change it at any time with `set`.
20
+ *
21
+ * @typedef {object} FakeModelState
22
+ * @property {'available' | 'downloadable' | 'downloading' | 'unavailable'} availability What `availability()` says.
23
+ * @property {number} createMs How long making a session takes (a cold start is ~18 s on a real Chrome).
24
+ * @property {number} answerMs How long an answer takes.
25
+ * @property {(prompt: string, options: PromptOptions) => string} answer The answer to a prompt.
26
+ * @property {boolean} failPrompt Whether answers fail (the model gave up).
27
+ * @property {boolean} failDownload Whether a download fails part way.
28
+ */
29
+
30
+ /**
31
+ * What the fake was asked, for tests to look at.
32
+ *
33
+ * @typedef {object} FakeModelLog
34
+ * @property {LanguageModelOptions[]} availability Each `availability()`'s options.
35
+ * @property {LanguageModelOptions[]} created Each `create()`'s options.
36
+ * @property {string[]} prompts Each prompt.
37
+ * @property {PromptOptions[]} promptOptions Each prompt's options (signal, responseConstraint).
38
+ * @property {number} cloned Sessions cloned.
39
+ * @property {number} aborted Prompts and creates stopped by their signal.
40
+ * @property {number} destroyed Sessions let go.
41
+ */
42
+
43
+ /**
44
+ * A fake Prompt API with its state, its log, and `set` to change the state.
45
+ *
46
+ * @typedef {LanguageModelStatic & {state: FakeModelState, log: FakeModelLog, set: (patch: Partial<FakeModelState>) => void}} FakeLanguageModel
47
+ */
48
+
49
+ /**
50
+ * Wait, unless the signal stops it first.
51
+ *
52
+ * @param {number} ms - How long.
53
+ * @param {AbortSignal | undefined} signal - Stops it.
54
+ * @returns {Promise<void>} Resolves after `ms`; rejects with an AbortError when stopped.
55
+ */
56
+ function wait(ms, signal) {
57
+ return new Promise((resolve, reject) => {
58
+ if (signal?.aborted) return reject(new DOMException('Aborted', 'AbortError'));
59
+ const timer = setTimeout(resolve, ms);
60
+ signal?.addEventListener('abort', () => {
61
+ clearTimeout(timer);
62
+ reject(new DOMException('Aborted', 'AbortError'));
63
+ }, { once: true });
64
+ });
65
+ }
66
+
67
+ /**
68
+ * A fake Prompt API.
69
+ *
70
+ * @param {Partial<FakeModelState>} [initial] - How it starts (default: ready, instant, answers “Fake answer”).
71
+ * @returns {FakeLanguageModel} The API, its state and log.
72
+ * @example
73
+ * const model = fakeLanguageModel({ createMs: 18_000 }); // a cold start
74
+ * const sessions = createModelSession({ model: () => model, options });
75
+ * (await sessions.ask('Hi', { timeoutMs: 2500 })).reason; // 'late'
76
+ */
77
+ export function fakeLanguageModel(initial = {}) {
78
+ /** @type {FakeModelState} */
79
+ const state = {
80
+ availability: 'available', createMs: 0, answerMs: 0, answer: () => 'Fake answer', failPrompt: false, failDownload: false, ...initial,
81
+ };
82
+ /** @type {FakeModelLog} */
83
+ const log = { availability: [], created: [], prompts: [], promptOptions: [], cloned: 0, aborted: 0, destroyed: 0 };
84
+
85
+ /**
86
+ * A session.
87
+ *
88
+ * @returns {LanguageModelSession} It.
89
+ */
90
+ const session = () => ({
91
+ async prompt(prompt, options = {}) {
92
+ log.prompts.push(prompt);
93
+ log.promptOptions.push(options);
94
+ try {
95
+ await wait(state.answerMs, options.signal);
96
+ } catch (error) {
97
+ log.aborted++;
98
+ throw error;
99
+ }
100
+ if (state.failPrompt) throw new DOMException('The model failed', 'UnknownError');
101
+ return state.answer(prompt, options);
102
+ },
103
+ async clone() {
104
+ log.cloned++;
105
+ return session();
106
+ },
107
+ destroy() {
108
+ log.destroyed++;
109
+ },
110
+ });
111
+
112
+ return {
113
+ state,
114
+ log,
115
+ set: (patch) => Object.assign(state, patch),
116
+ async availability(options = {}) {
117
+ log.availability.push(options);
118
+ return state.availability;
119
+ },
120
+ async create(options = {}) {
121
+ log.created.push(options);
122
+ if (state.availability === 'unavailable') throw new DOMException('Not supported', 'NotSupportedError');
123
+ if (state.availability !== 'available') {
124
+ // A download, reported as Chrome reports it, then the model is here.
125
+ const monitor = new EventTarget();
126
+ options.monitor?.(monitor);
127
+ for (const loaded of [0, 0.5, 1]) {
128
+ if (state.failDownload && loaded === 1) throw new DOMException('Download failed', 'NetworkError');
129
+ monitor.dispatchEvent(Object.assign(new Event('downloadprogress'), { loaded }));
130
+ }
131
+ state.availability = 'available';
132
+ }
133
+ try {
134
+ await wait(state.createMs, options.signal);
135
+ } catch (error) {
136
+ log.aborted++;
137
+ throw error;
138
+ }
139
+ return session();
140
+ },
141
+ };
142
+ }
@@ -49,6 +49,8 @@ export type FakeChromeStorage = {
49
49
  */
50
50
  settled: () => Promise<void>;
51
51
  };
52
+ /** All of sync's items together may take this many bytes (`chrome.storage.sync.QUOTA_BYTES`). */
53
+ export declare const SYNC_QUOTA_BYTES = 102400;
52
54
  /**
53
55
  * Make a fake `chrome.storage`.
54
56
  *
@@ -49,6 +49,17 @@ export const SYNC_WRITES_PER_MINUTE = 120;
49
49
  */
50
50
  const viaJson = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
51
51
 
52
+ /** All of sync's items together may take this many bytes (`chrome.storage.sync.QUOTA_BYTES`). */
53
+ export const SYNC_QUOTA_BYTES = 102_400;
54
+
55
+ /**
56
+ * The bytes an area's items take, as Chrome counts sync's: each key plus its value's JSON, in UTF-8.
57
+ *
58
+ * @param {Record<string, unknown>} items - The items.
59
+ * @returns {number} Bytes.
60
+ */
61
+ const totalBytes = (items) => Object.entries(items).reduce((sum, [key, value]) => sum + new TextEncoder().encode(key + JSON.stringify(value)).length, 0);
62
+
52
63
  /**
53
64
  * Make a fake `chrome.storage`.
54
65
  *
@@ -72,10 +83,10 @@ export function fakeChromeStorage({ local = {}, sync = {}, session = {}, now = D
72
83
  /**
73
84
  * @param {string} name
74
85
  * @param {Record<string, unknown>} initialData
75
- * @param {{quotaPerItem?: number, writesPerMinute?: number}} [limits]
86
+ * @param {{quotaPerItem?: number, quotaBytes?: number, writesPerMinute?: number}} [limits]
76
87
  * @returns {FakeArea}
77
88
  */
78
- function area(name, initialData, { quotaPerItem, writesPerMinute } = {}) {
89
+ function area(name, initialData, { quotaPerItem, quotaBytes, writesPerMinute } = {}) {
79
90
  /** @type {Record<string, any>} */
80
91
  const data = viaJson(initialData);
81
92
  /** @type {number[]} */
@@ -108,6 +119,7 @@ export function fakeChromeStorage({ local = {}, sync = {}, session = {}, now = D
108
119
  throw new Error('Resource::kQuotaBytesPerItem quota exceeded');
109
120
  }
110
121
  }
122
+ if (quotaBytes && totalBytes({ ...data, ...items }) > quotaBytes) throw new Error('Resource::kQuotaBytes quota exceeded');
111
123
  take();
112
124
  /** @type {Record<string, StorageChange>} */
113
125
  const changes = {};
@@ -135,7 +147,7 @@ export function fakeChromeStorage({ local = {}, sync = {}, session = {}, now = D
135
147
 
136
148
  return {
137
149
  local: area('local', local),
138
- sync: area('sync', sync, { quotaPerItem: MAX_SYNC_ITEM_BYTES, writesPerMinute: SYNC_WRITES_PER_MINUTE }),
150
+ sync: area('sync', sync, { quotaPerItem: MAX_SYNC_ITEM_BYTES, quotaBytes: SYNC_QUOTA_BYTES, writesPerMinute: SYNC_WRITES_PER_MINUTE }),
139
151
  session: area('session', session),
140
152
  onChanged: { addListener: (listener) => listeners.push(listener) },
141
153
  async settled() {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
2
+ * The kit's test helpers: a fake `chrome.storage`, a fake on-device model, Playwright helpers for
3
3
  * end-to-end tests (time limits, a pool, a browser that passes for a
4
4
  * person's) and a server for their pages, Chrome's install warnings and
5
5
  * saved tab groups, and checks for a packaging test. Node only.
@@ -8,17 +8,26 @@
8
8
  */
9
9
  export { TEST_BUILDS_ENV, testBuildDir } from '../build/test-build.js';
10
10
  export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
11
- export { CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until, } from './e2e.js';
12
- export { eachLimited } from './pool.js';
13
- export { serve } from './server.js';
14
- export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
11
+ export { CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, runChecks, until, } from './e2e.js';
12
+ export { exitPoint, parseLiveOptions, parseProxy } from './live.js';
13
+ export { eachLimited } from '../time/pool.js';
14
+ export { filesIn, launchDownloading } from './downloads.js';
15
+ export { fakeLanguageModel } from './fake-model.js';
16
+ export { messageLookup, readMessages } from './messages.js';
17
+ export { serve, testCertificate } from './server.js';
18
+ export { SYNC_QUOTA_BYTES, SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
15
19
  export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
16
20
  export { savedTabGroups } from './saved-groups.js';
17
21
  export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
18
22
  export type CheckRunner = import('./e2e.js').CheckRunner;
23
+ export type FakeLanguageModel = import('./fake-model.js').FakeLanguageModel;
24
+ export type FakeModelState = import('./fake-model.js').FakeModelState;
19
25
  export type ExtensionProfile = import('./e2e.js').ExtensionProfile;
20
26
  export type LaunchedExtension = import('./e2e.js').LaunchedExtension;
21
27
  export type LaunchOptions = import('./e2e.js').LaunchOptions;
28
+ export type Messages = import('./messages.js').Messages;
29
+ export type Exit = import('./live.js').Exit;
30
+ export type LiveOptions = import('./live.js').LiveOptions;
22
31
  export type SavedTabGroup = import('./saved-groups.js').SavedTabGroup;
23
32
  export type Manifest = import('./warnings.js').Manifest;
24
33
  export type Reply = import('./server.js').Reply;
@@ -28,9 +37,14 @@ export type TestServer = import('./server.js').TestServer;
28
37
  * The helpers' types, for a test's JSDoc.
29
38
  *
30
39
  * @typedef {import('./e2e.js').CheckRunner} CheckRunner
40
+ * @typedef {import('./fake-model.js').FakeLanguageModel} FakeLanguageModel
41
+ * @typedef {import('./fake-model.js').FakeModelState} FakeModelState
31
42
  * @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
32
43
  * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
33
44
  * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
45
+ * @typedef {import('./messages.js').Messages} Messages
46
+ * @typedef {import('./live.js').Exit} Exit
47
+ * @typedef {import('./live.js').LiveOptions} LiveOptions
34
48
  * @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
35
49
  * @typedef {import('./warnings.js').Manifest} Manifest
36
50
  * @typedef {import('./server.js').Reply} Reply
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
2
+ * The kit's test helpers: a fake `chrome.storage`, a fake on-device model, Playwright helpers for
3
3
  * end-to-end tests (time limits, a pool, a browser that passes for a
4
4
  * person's) and a server for their pages, Chrome's install warnings and
5
5
  * saved tab groups, and checks for a packaging test. Node only.
@@ -10,11 +10,15 @@
10
10
  export { TEST_BUILDS_ENV, testBuildDir } from '../build/test-build.js';
11
11
  export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
12
12
  export {
13
- CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until,
13
+ CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, runChecks, until,
14
14
  } from './e2e.js';
15
- export { eachLimited } from './pool.js';
16
- export { serve } from './server.js';
17
- export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
15
+ export { exitPoint, parseLiveOptions, parseProxy } from './live.js';
16
+ export { eachLimited } from '../time/pool.js';
17
+ export { filesIn, launchDownloading } from './downloads.js';
18
+ export { fakeLanguageModel } from './fake-model.js';
19
+ export { messageLookup, readMessages } from './messages.js';
20
+ export { serve, testCertificate } from './server.js';
21
+ export { SYNC_QUOTA_BYTES, SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
18
22
  export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
19
23
  export { savedTabGroups } from './saved-groups.js';
20
24
  export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
@@ -23,9 +27,14 @@ export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings
23
27
  * The helpers' types, for a test's JSDoc.
24
28
  *
25
29
  * @typedef {import('./e2e.js').CheckRunner} CheckRunner
30
+ * @typedef {import('./fake-model.js').FakeLanguageModel} FakeLanguageModel
31
+ * @typedef {import('./fake-model.js').FakeModelState} FakeModelState
26
32
  * @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
27
33
  * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
28
34
  * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
35
+ * @typedef {import('./messages.js').Messages} Messages
36
+ * @typedef {import('./live.js').Exit} Exit
37
+ * @typedef {import('./live.js').LiveOptions} LiveOptions
29
38
  * @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
30
39
  * @typedef {import('./warnings.js').Manifest} Manifest
31
40
  * @typedef {import('./server.js').Reply} Reply
@@ -0,0 +1,146 @@
1
+ /**
2
+ * What a live checker (a run over real sites, never in CI) shares: its
3
+ * command line (which sites, how many at once, a proxy, how the browser
4
+ * looks) and where its traffic leaves for the internet, which decides what
5
+ * many sites show (cookie banners, prices, currency).
6
+ *
7
+ * @module testing/live
8
+ */
9
+ import type { APIRequestContext } from 'playwright';
10
+ export type Proxy = {
11
+ /**
12
+ * e.g. "http://uk.example.net:8080" or "socks5://127.0.0.1:1080".
13
+ */
14
+ server: string;
15
+ /**
16
+ * From the address, if it has one.
17
+ */
18
+ username?: string;
19
+ /**
20
+ * From the address, if it has one.
21
+ */
22
+ password?: string;
23
+ };
24
+ export type LiveOptions = {
25
+ /**
26
+ * Only sites whose address or name contains one of these (any case).
27
+ */
28
+ only: string[];
29
+ /**
30
+ * Only these categories (by id).
31
+ */
32
+ categories: string[];
33
+ /**
34
+ * Sites checked at once.
35
+ */
36
+ concurrency: number;
37
+ /**
38
+ * The proxy every request goes through.
39
+ */
40
+ proxy: Proxy | null;
41
+ /**
42
+ * The proxy's address without its password, for the output.
43
+ */
44
+ proxyLabel: string;
45
+ /**
46
+ * The browser's language (navigator.language, Accept-Language).
47
+ */
48
+ locale: string;
49
+ /**
50
+ * The browser's time zone.
51
+ */
52
+ timezone: string;
53
+ /**
54
+ * Where the report goes ('' for the checker's default).
55
+ */
56
+ out: string;
57
+ /**
58
+ * Show the usage and stop.
59
+ */
60
+ help: boolean;
61
+ };
62
+ /** @import { APIRequestContext } from 'playwright' */
63
+ /**
64
+ * A proxy, as Playwright takes it.
65
+ *
66
+ * @typedef {object} Proxy
67
+ * @property {string} server e.g. "http://uk.example.net:8080" or "socks5://127.0.0.1:1080".
68
+ * @property {string} [username] From the address, if it has one.
69
+ * @property {string} [password] From the address, if it has one.
70
+ */
71
+ /**
72
+ * A live checker's options.
73
+ *
74
+ * @typedef {object} LiveOptions
75
+ * @property {string[]} only Only sites whose address or name contains one of these (any case).
76
+ * @property {string[]} categories Only these categories (by id).
77
+ * @property {number} concurrency Sites checked at once.
78
+ * @property {Proxy | null} proxy The proxy every request goes through.
79
+ * @property {string} proxyLabel The proxy's address without its password, for the output.
80
+ * @property {string} locale The browser's language (navigator.language, Accept-Language).
81
+ * @property {string} timezone The browser's time zone.
82
+ * @property {string} out Where the report goes ('' for the checker's default).
83
+ * @property {boolean} help Show the usage and stop.
84
+ */
85
+ /**
86
+ * Read a proxy address.
87
+ *
88
+ * @param {string} address - e.g. "http://user:pass@host:3128".
89
+ * @returns {{ proxy: Proxy, label: string }} For Playwright, and for the output (no password).
90
+ * @throws {Error} When it isn't an address.
91
+ * @example
92
+ * parseProxy('http://me:secret@uk.example:8080').label; // 'http://me:•••@uk.example:8080'
93
+ */
94
+ export declare function parseProxy(address: string): {
95
+ proxy: Proxy;
96
+ label: string;
97
+ };
98
+ /**
99
+ * Read a live checker's command line: `--only <word>` (repeatable, or bare
100
+ * words), `--category <id>` (repeatable), `--concurrency <n>`, `--proxy <url>`
101
+ * (default `HTTPS_PROXY`), `--locale <tag>`, `--timezone <zone>`, `--out <dir>`,
102
+ * `--help`. A value may follow an `=` ("--only=amazon"); commas list several.
103
+ *
104
+ * @param {readonly string[]} argv - The arguments after the script.
105
+ * @param {Readonly<Record<string, string | undefined>>} env - The environment (HTTPS_PROXY, https_proxy).
106
+ * @param {{ usage: string, concurrency?: number, maxConcurrency?: number, locale?: string, timezone?: string }} defaults - The
107
+ * usage text (shown with an unknown option), and the defaults: 3 at once (at most 8), en-GB, Europe/London.
108
+ * @returns {LiveOptions} The options.
109
+ * @throws {Error} On an unknown option or a bad value.
110
+ * @example
111
+ * parseLiveOptions(['--category', 'shops', 'amazon'], {}, { usage }).only; // ['amazon']
112
+ */
113
+ export declare function parseLiveOptions(argv: readonly string[], env: Readonly<Record<string, string | undefined>>, { usage, concurrency, maxConcurrency, locale, timezone }: {
114
+ usage: string;
115
+ concurrency?: number;
116
+ maxConcurrency?: number;
117
+ locale?: string;
118
+ timezone?: string;
119
+ }): LiveOptions;
120
+ export type Exit = {
121
+ /**
122
+ * The public address.
123
+ */
124
+ ip: string;
125
+ /**
126
+ * Its ISO country code ("GB", "TH").
127
+ */
128
+ country: string;
129
+ /**
130
+ * Readable: city and organisation, where the service says.
131
+ */
132
+ where: string;
133
+ /**
134
+ * The service that answered.
135
+ */
136
+ source: string;
137
+ };
138
+ /**
139
+ * Where the browser's traffic leaves for the internet: its public IP's
140
+ * country, asked once through the same proxy the browser uses. Sites go by
141
+ * that address, not the browser's language or time zone.
142
+ *
143
+ * @param {APIRequestContext} request - A browser context's `request` (it uses the context's proxy).
144
+ * @returns {Promise<Exit | null>} null when no service answered.
145
+ */
146
+ export declare function exitPoint(request: APIRequestContext): Promise<Exit | null>;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * What a live checker (a run over real sites, never in CI) shares: its
3
+ * command line (which sites, how many at once, a proxy, how the browser
4
+ * looks) and where its traffic leaves for the internet, which decides what
5
+ * many sites show (cookie banners, prices, currency).
6
+ *
7
+ * @module testing/live
8
+ */
9
+
10
+ /** @import { APIRequestContext } from 'playwright' */
11
+
12
+ /**
13
+ * A proxy, as Playwright takes it.
14
+ *
15
+ * @typedef {object} Proxy
16
+ * @property {string} server e.g. "http://uk.example.net:8080" or "socks5://127.0.0.1:1080".
17
+ * @property {string} [username] From the address, if it has one.
18
+ * @property {string} [password] From the address, if it has one.
19
+ */
20
+
21
+ /**
22
+ * A live checker's options.
23
+ *
24
+ * @typedef {object} LiveOptions
25
+ * @property {string[]} only Only sites whose address or name contains one of these (any case).
26
+ * @property {string[]} categories Only these categories (by id).
27
+ * @property {number} concurrency Sites checked at once.
28
+ * @property {Proxy | null} proxy The proxy every request goes through.
29
+ * @property {string} proxyLabel The proxy's address without its password, for the output.
30
+ * @property {string} locale The browser's language (navigator.language, Accept-Language).
31
+ * @property {string} timezone The browser's time zone.
32
+ * @property {string} out Where the report goes ('' for the checker's default).
33
+ * @property {boolean} help Show the usage and stop.
34
+ */
35
+
36
+ /**
37
+ * Read a proxy address.
38
+ *
39
+ * @param {string} address - e.g. "http://user:pass@host:3128".
40
+ * @returns {{ proxy: Proxy, label: string }} For Playwright, and for the output (no password).
41
+ * @throws {Error} When it isn't an address.
42
+ * @example
43
+ * parseProxy('http://me:secret@uk.example:8080').label; // 'http://me:•••@uk.example:8080'
44
+ */
45
+ export function parseProxy(address) {
46
+ const url = new URL(address.includes('://') ? address : `http://${address}`);
47
+ if (!url.hostname) throw new Error(`Not a proxy address: ${address}`);
48
+ const server = `${url.protocol}//${url.host}`;
49
+ const username = decodeURIComponent(url.username);
50
+ const password = decodeURIComponent(url.password);
51
+ return {
52
+ proxy: { server, ...(username && { username }), ...(password && { password }) },
53
+ label: `${url.protocol}//${username ? `${username}${password ? ':•••' : ''}@` : ''}${url.host}`,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Read a live checker's command line: `--only <word>` (repeatable, or bare
59
+ * words), `--category <id>` (repeatable), `--concurrency <n>`, `--proxy <url>`
60
+ * (default `HTTPS_PROXY`), `--locale <tag>`, `--timezone <zone>`, `--out <dir>`,
61
+ * `--help`. A value may follow an `=` ("--only=amazon"); commas list several.
62
+ *
63
+ * @param {readonly string[]} argv - The arguments after the script.
64
+ * @param {Readonly<Record<string, string | undefined>>} env - The environment (HTTPS_PROXY, https_proxy).
65
+ * @param {{ usage: string, concurrency?: number, maxConcurrency?: number, locale?: string, timezone?: string }} defaults - The
66
+ * usage text (shown with an unknown option), and the defaults: 3 at once (at most 8), en-GB, Europe/London.
67
+ * @returns {LiveOptions} The options.
68
+ * @throws {Error} On an unknown option or a bad value.
69
+ * @example
70
+ * parseLiveOptions(['--category', 'shops', 'amazon'], {}, { usage }).only; // ['amazon']
71
+ */
72
+ export function parseLiveOptions(argv, env, { usage, concurrency = 3, maxConcurrency = 8, locale = 'en-GB', timezone = 'Europe/London' }) {
73
+ /** @type {LiveOptions} */
74
+ const options = { only: [], categories: [], concurrency, proxy: null, proxyLabel: '', locale, timezone, out: '', help: false };
75
+ let proxy = env.HTTPS_PROXY || env.https_proxy || '';
76
+ for (let at = 0; at < argv.length; at += 1) {
77
+ const arg = argv[at];
78
+ const equals = arg.startsWith('--') ? arg.indexOf('=') : -1;
79
+ const flag = equals > 0 ? arg.slice(0, equals) : arg;
80
+ const inline = equals > 0 ? arg.slice(equals + 1) : undefined;
81
+ const value = () => {
82
+ const next = inline ?? argv[++at];
83
+ if (next === undefined || next === '') throw new Error(`${flag} needs a value`);
84
+ return next;
85
+ };
86
+ switch (flag) {
87
+ case '--only': options.only.push(...value().split(',')); break;
88
+ case '--category': options.categories.push(...value().split(',')); break;
89
+ case '--concurrency': {
90
+ const count = Number(value());
91
+ if (!Number.isInteger(count) || count < 1 || count > maxConcurrency) throw new Error(`--concurrency must be 1 to ${maxConcurrency}`);
92
+ options.concurrency = count;
93
+ break;
94
+ }
95
+ case '--proxy': proxy = value(); break;
96
+ case '--locale': options.locale = value(); break;
97
+ case '--timezone': options.timezone = value(); break;
98
+ case '--out': options.out = value(); break;
99
+ case '--help': case '-h': options.help = true; break;
100
+ default:
101
+ if (flag.startsWith('-')) throw new Error(`Unknown option ${flag}\n\n${usage}`);
102
+ options.only.push(flag);
103
+ }
104
+ }
105
+ if (proxy) ({ proxy: options.proxy, label: options.proxyLabel } = parseProxy(proxy));
106
+ return options;
107
+ }
108
+
109
+ /**
110
+ * The exit point.
111
+ *
112
+ * @typedef {object} Exit
113
+ * @property {string} ip The public address.
114
+ * @property {string} country Its ISO country code ("GB", "TH").
115
+ * @property {string} where Readable: city and organisation, where the service says.
116
+ * @property {string} source The service that answered.
117
+ */
118
+
119
+ /**
120
+ * Lookup services that need no key, tried in order, each with how to read its answer.
121
+ *
122
+ * @type {readonly { url: string, read: (data: Record<string, unknown>) => { ip: unknown, country: unknown, where: unknown[] } }[]}
123
+ */
124
+ const SERVICES = [
125
+ { url: 'https://ipinfo.io/json', read: (data) => ({ ip: data.ip, country: data.country, where: [data.city, data.org] }) },
126
+ { url: 'https://api.country.is/', read: (data) => ({ ip: data.ip, country: data.country, where: [] }) },
127
+ { url: 'https://ipapi.co/json/', read: (data) => ({ ip: data.ip, country: data.country_code, where: [data.city, data.org] }) },
128
+ ];
129
+
130
+ /**
131
+ * Where the browser's traffic leaves for the internet: its public IP's
132
+ * country, asked once through the same proxy the browser uses. Sites go by
133
+ * that address, not the browser's language or time zone.
134
+ *
135
+ * @param {APIRequestContext} request - A browser context's `request` (it uses the context's proxy).
136
+ * @returns {Promise<Exit | null>} null when no service answered.
137
+ */
138
+ export async function exitPoint(request) {
139
+ for (const { url, read } of SERVICES) {
140
+ try {
141
+ const response = await request.get(url, { timeout: 10000, headers: { accept: 'application/json' } });
142
+ if (!response.ok()) continue;
143
+ const { ip, country, where } = read(await response.json());
144
+ if (typeof country !== 'string' || !country) continue;
145
+ return { ip: String(ip ?? ''), country: country.toUpperCase(), where: where.filter((part) => typeof part === 'string' && part).join(', '), source: new URL(url).host };
146
+ } catch {
147
+ // The next service.
148
+ }
149
+ }
150
+ return null;
151
+ }
@@ -0,0 +1,33 @@
1
+ export type Messages = Record<string, {
2
+ message: string;
3
+ description?: string;
4
+ placeholders?: Record<string, {
5
+ content: string;
6
+ }>;
7
+ }>;
8
+ /**
9
+ * One locale's messages.json.
10
+ *
11
+ * @typedef {Record<string, {message: string, description?: string, placeholders?: Record<string, {content: string}>}>} Messages
12
+ */
13
+ /**
14
+ * Read a locale's messages.
15
+ *
16
+ * @param {string} extensionDir - The extension's folder (with `_locales/`).
17
+ * @param {string} locale - "en" or "en_GB".
18
+ * @returns {Messages} Its messages.
19
+ * @example
20
+ * readMessages('extension', 'en_GB').optionsColour.message;
21
+ */
22
+ export declare function readMessages(extensionDir: string, locale: string): Messages;
23
+ /**
24
+ * `chrome.i18n.getMessage` over a locale's real messages ('' for a missing key, as Chrome gives).
25
+ *
26
+ * @param {string} extensionDir - The extension's folder.
27
+ * @param {string} [locale] - Default "en"; another locale is read over en.
28
+ * @returns {(key: string, substitutions?: string | string[]) => string} The lookup.
29
+ * @example
30
+ * const t = messageLookup('extension');
31
+ * t('optionsMoreActions', ['example.com']); // 'More actions for example.com'
32
+ */
33
+ export declare function messageLookup(extensionDir: string, locale?: string): (key: string, substitutions?: string | string[]) => string;