@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.
- package/README.md +120 -4
- package/package.json +2 -2
- package/src/ai/model.d.ts +317 -0
- package/src/ai/model.js +312 -0
- package/src/build/vendor.js +1 -1
- package/src/content/slices.d.ts +63 -0
- package/src/content/slices.js +80 -0
- package/src/content/watch.d.ts +71 -0
- package/src/content/watch.js +95 -0
- package/src/index.d.ts +35 -4
- package/src/index.js +25 -4
- package/src/scripting/registration.d.ts +36 -0
- package/src/scripting/registration.js +54 -0
- package/src/styles/chrome-ui.css +11 -0
- package/src/testing/downloads.d.ts +41 -0
- package/src/testing/downloads.js +70 -0
- package/src/testing/e2e.d.ts +15 -0
- package/src/testing/e2e.js +26 -1
- package/src/testing/fake-model.d.ts +87 -0
- package/src/testing/fake-model.js +142 -0
- package/src/testing/fake-storage.d.ts +2 -0
- package/src/testing/fake-storage.js +15 -3
- package/src/testing/index.d.ts +19 -5
- package/src/testing/index.js +14 -5
- package/src/testing/live.d.ts +146 -0
- package/src/testing/live.js +151 -0
- package/src/testing/messages.d.ts +33 -0
- package/src/testing/messages.js +51 -0
- package/src/testing/server.d.ts +23 -3
- package/src/testing/server.js +36 -6
- package/src/{testing → time}/pool.d.ts +4 -3
- package/src/{testing → time}/pool.js +4 -3
- package/src/ui/model-row.d.ts +111 -0
- package/src/ui/model-row.js +108 -0
- package/src/ui/text-dialog.d.ts +44 -7
- package/src/ui/text-dialog.js +54 -16
|
@@ -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() {
|
package/src/testing/index.d.ts
CHANGED
|
@@ -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 {
|
|
13
|
-
export {
|
|
14
|
-
export {
|
|
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
|
package/src/testing/index.js
CHANGED
|
@@ -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 {
|
|
16
|
-
export {
|
|
17
|
-
export {
|
|
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;
|