@extenshi/cli 0.24.0 → 0.25.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 +1 -1
- package/dist/amo-source.d.ts.map +1 -1
- package/dist/amo-source.js +5 -0
- package/dist/amo-source.js.map +1 -1
- package/dist/amowebstore-md.d.ts +26 -0
- package/dist/amowebstore-md.d.ts.map +1 -1
- package/dist/amowebstore-md.js +32 -3
- package/dist/amowebstore-md.js.map +1 -1
- package/dist/auto-update.d.ts +46 -3
- package/dist/auto-update.d.ts.map +1 -1
- package/dist/auto-update.js +94 -19
- package/dist/auto-update.js.map +1 -1
- package/dist/chromewebstore-md.d.ts +7 -0
- package/dist/chromewebstore-md.d.ts.map +1 -1
- package/dist/chromewebstore-md.js +20 -3
- package/dist/chromewebstore-md.js.map +1 -1
- package/dist/cli.js +31 -9
- package/dist/cli.js.map +1 -1
- package/dist/edgewebstore-md.d.ts +14 -0
- package/dist/edgewebstore-md.d.ts.map +1 -1
- package/dist/edgewebstore-md.js +20 -5
- package/dist/edgewebstore-md.js.map +1 -1
- package/dist/evidence-ingest.d.ts +67 -5
- package/dist/evidence-ingest.d.ts.map +1 -1
- package/dist/evidence-ingest.js +146 -21
- package/dist/evidence-ingest.js.map +1 -1
- package/dist/generate-listing.d.ts +18 -6
- package/dist/generate-listing.d.ts.map +1 -1
- package/dist/generate-listing.js +62 -19
- package/dist/generate-listing.js.map +1 -1
- package/dist/json-ld.d.ts +43 -5
- package/dist/json-ld.d.ts.map +1 -1
- package/dist/json-ld.js +53 -1
- package/dist/json-ld.js.map +1 -1
- package/dist/landing-sections.d.ts +21 -3
- package/dist/landing-sections.d.ts.map +1 -1
- package/dist/landing-sections.js +63 -17
- package/dist/landing-sections.js.map +1 -1
- package/dist/listing-data-flows.d.ts +140 -0
- package/dist/listing-data-flows.d.ts.map +1 -0
- package/dist/listing-data-flows.js +454 -0
- package/dist/listing-data-flows.js.map +1 -0
- package/dist/localize-parity.d.ts +26 -0
- package/dist/localize-parity.d.ts.map +1 -0
- package/dist/localize-parity.js +227 -0
- package/dist/localize-parity.js.map +1 -0
- package/dist/localize.d.ts +6 -0
- package/dist/localize.d.ts.map +1 -1
- package/dist/localize.js +4 -4
- package/dist/localize.js.map +1 -1
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +1 -2
- package/dist/media.js.map +1 -1
- package/dist/package.d.ts +13 -0
- package/dist/package.d.ts.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/dist/page.d.ts +16 -0
- package/dist/page.d.ts.map +1 -1
- package/dist/page.js +49 -4
- package/dist/page.js.map +1 -1
- package/dist/permission-justifications.d.ts +19 -1
- package/dist/permission-justifications.d.ts.map +1 -1
- package/dist/permission-justifications.js +129 -0
- package/dist/permission-justifications.js.map +1 -1
- package/dist/png.d.ts.map +1 -1
- package/dist/png.js +92 -34
- package/dist/png.js.map +1 -1
- package/dist/review-checks.d.ts +6 -0
- package/dist/review-checks.d.ts.map +1 -1
- package/dist/review-checks.js +8 -5
- package/dist/review-checks.js.map +1 -1
- package/dist/screenshots.d.ts.map +1 -1
- package/dist/screenshots.js +2 -3
- package/dist/screenshots.js.map +1 -1
- package/dist/smoke.d.ts +54 -6
- package/dist/smoke.d.ts.map +1 -1
- package/dist/smoke.js +302 -26
- package/dist/smoke.js.map +1 -1
- package/dist/store-assets.d.ts +20 -3
- package/dist/store-assets.d.ts.map +1 -1
- package/dist/store-assets.js +13 -7
- package/dist/store-assets.js.map +1 -1
- package/package.json +1 -1
package/dist/smoke.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `extenshi test smoke <zip> --browser chromium|edge|firefox` — install the
|
|
3
|
-
* EXACT packaged zip in a real browser and check
|
|
4
|
-
* it loads, its service worker / background starts,
|
|
5
|
-
* manifest errors, and its popup / options / side-panel
|
|
6
|
-
* console errors.
|
|
2
|
+
* `extenshi test smoke <zip|dir> --browser chromium|edge|firefox` — install the
|
|
3
|
+
* EXACT packaged zip (or an unpacked build folder) in a real browser and check
|
|
4
|
+
* that it actually works: it loads, its service worker / background starts,
|
|
5
|
+
* the browser reports no manifest errors, and its popup / options / side-panel
|
|
6
|
+
* pages open without console errors. With `--site <url>` it then opens each
|
|
7
|
+
* site and checks that the `content_scripts` entries matching it were injected.
|
|
7
8
|
*
|
|
8
9
|
* Engines (all optional, resolved lazily — the CLI install stays small):
|
|
9
10
|
* chromium Playwright's bundled Chromium (`npx playwright install chromium`).
|
|
@@ -15,7 +16,15 @@
|
|
|
15
16
|
*
|
|
16
17
|
* Chromium engines get the zip extracted to a temp directory (they load
|
|
17
18
|
* unpacked extensions only); Firefox installs the zip file itself. Either way
|
|
18
|
-
* the bytes are the package's, and the result carries its sha256.
|
|
19
|
+
* the bytes are the package's, and the result carries its sha256. A folder is
|
|
20
|
+
* read the way `extenshi package` reads one (dot-entries and node_modules left
|
|
21
|
+
* out) into a reproducible zip, which then takes the same path.
|
|
22
|
+
*
|
|
23
|
+
* Content scripts (Chromium/Edge only for now) are observed over the DevTools
|
|
24
|
+
* protocol, without the extension's cooperation: the browser reports each
|
|
25
|
+
* extension file it compiles into the page (Debugger.scriptParsed) and each
|
|
26
|
+
* stylesheet it injects (CSS origin "injected"). That proves injection, not
|
|
27
|
+
* that the script then did its job.
|
|
19
28
|
*
|
|
20
29
|
* Limits, stated in every result: the popup is opened as a tab, not from the
|
|
21
30
|
* toolbar, and no page is interacted with — this proves the package boots
|
|
@@ -34,6 +43,8 @@ export interface SmokeCheck {
|
|
|
34
43
|
}
|
|
35
44
|
export interface SmokeResult {
|
|
36
45
|
artifact: string;
|
|
46
|
+
/** `directory`: an unpacked folder; `sha256` is then that of the zip built from it. */
|
|
47
|
+
source: 'zip' | 'directory';
|
|
37
48
|
sha256: string;
|
|
38
49
|
browser: SmokeEngine;
|
|
39
50
|
engine: {
|
|
@@ -55,6 +66,41 @@ export interface ExtensionPage {
|
|
|
55
66
|
}
|
|
56
67
|
/** The extension's own pages a user can open, as the manifest declares them. */
|
|
57
68
|
export declare function extensionPages(manifest: Json): ExtensionPage[];
|
|
69
|
+
/**
|
|
70
|
+
* A manifest match pattern as the browser applies it to a page: `<all_urls>`;
|
|
71
|
+
* scheme `*` = http or https; host `*.example.com` = the host and its
|
|
72
|
+
* subdomains; no port = any port; the path glob covers path + query. A pattern
|
|
73
|
+
* the browser would reject matches nothing.
|
|
74
|
+
*/
|
|
75
|
+
export declare function matchesPattern(pattern: string, url: URL): boolean;
|
|
76
|
+
export interface MatchingContentScript {
|
|
77
|
+
/** Position in manifest.content_scripts. */
|
|
78
|
+
index: number;
|
|
79
|
+
js: string[];
|
|
80
|
+
css: string[];
|
|
81
|
+
}
|
|
82
|
+
/** The `content_scripts` entries the browser injects into a top-level page at `url`. */
|
|
83
|
+
export declare function contentScriptsFor(manifest: Json, url: string): MatchingContentScript[];
|
|
84
|
+
/** What the browser reported while one site was open (top frame only). */
|
|
85
|
+
export interface SiteObservation {
|
|
86
|
+
/** The URL as given. */
|
|
87
|
+
site: string;
|
|
88
|
+
/** Where the tab ended up (redirects); absent when it did not open. */
|
|
89
|
+
finalUrl?: string;
|
|
90
|
+
openError?: string;
|
|
91
|
+
/** Extension files compiled into the page, relative to the extension root. */
|
|
92
|
+
scripts: string[];
|
|
93
|
+
/** Stylesheets the browser injected for the extension. */
|
|
94
|
+
injectedSheets: number;
|
|
95
|
+
/** Errors attributed to the extension (its isolated world, its files, its URLs). */
|
|
96
|
+
errors: string[];
|
|
97
|
+
}
|
|
98
|
+
/** The checks one visited site yields: injection of every matching entry, then extension errors. */
|
|
99
|
+
export declare function siteChecks(manifest: Json, observation: SiteObservation): SmokeCheck[];
|
|
100
|
+
/** A `--site` value: an absolute http(s) URL. */
|
|
101
|
+
export declare function parseSiteUrl(value: string): string;
|
|
102
|
+
/** A directory (an unpacked build) rather than a package file. */
|
|
103
|
+
export declare function isExtensionDirectory(file: string): boolean;
|
|
58
104
|
/** Checks that need no browser: the files the manifest names exist, and Firefox has a real add-on ID. */
|
|
59
105
|
export declare function staticChecks(manifest: Json, paths: Set<string>, engine: SmokeEngine): SmokeCheck[];
|
|
60
106
|
export interface SmokeOptions {
|
|
@@ -69,6 +115,8 @@ export interface SmokeOptions {
|
|
|
69
115
|
browserPath?: string;
|
|
70
116
|
/** Where to resolve Playwright from first (default: cwd). */
|
|
71
117
|
cwd?: string;
|
|
118
|
+
/** http(s) pages to open after install, checking the content scripts that match each (Chromium/Edge). */
|
|
119
|
+
sites?: string[];
|
|
72
120
|
}
|
|
73
121
|
export declare function runSmoke(options: SmokeOptions): Promise<SmokeResult>;
|
|
74
122
|
/** Resolve Playwright from the project first, then next to the CLI (global installs, CI tool prefixes). */
|
package/dist/smoke.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"smoke.d.ts","sourceRoot":"","sources":["../src/smoke.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"smoke.d.ts","sourceRoot":"","sources":["../src/smoke.ts"],"names":[],"mappings":"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"}
|
package/dist/smoke.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `extenshi test smoke <zip> --browser chromium|edge|firefox` — install the
|
|
3
|
-
* EXACT packaged zip in a real browser and check
|
|
4
|
-
* it loads, its service worker / background starts,
|
|
5
|
-
* manifest errors, and its popup / options / side-panel
|
|
6
|
-
* console errors.
|
|
2
|
+
* `extenshi test smoke <zip|dir> --browser chromium|edge|firefox` — install the
|
|
3
|
+
* EXACT packaged zip (or an unpacked build folder) in a real browser and check
|
|
4
|
+
* that it actually works: it loads, its service worker / background starts,
|
|
5
|
+
* the browser reports no manifest errors, and its popup / options / side-panel
|
|
6
|
+
* pages open without console errors. With `--site <url>` it then opens each
|
|
7
|
+
* site and checks that the `content_scripts` entries matching it were injected.
|
|
7
8
|
*
|
|
8
9
|
* Engines (all optional, resolved lazily — the CLI install stays small):
|
|
9
10
|
* chromium Playwright's bundled Chromium (`npx playwright install chromium`).
|
|
@@ -15,7 +16,15 @@
|
|
|
15
16
|
*
|
|
16
17
|
* Chromium engines get the zip extracted to a temp directory (they load
|
|
17
18
|
* unpacked extensions only); Firefox installs the zip file itself. Either way
|
|
18
|
-
* the bytes are the package's, and the result carries its sha256.
|
|
19
|
+
* the bytes are the package's, and the result carries its sha256. A folder is
|
|
20
|
+
* read the way `extenshi package` reads one (dot-entries and node_modules left
|
|
21
|
+
* out) into a reproducible zip, which then takes the same path.
|
|
22
|
+
*
|
|
23
|
+
* Content scripts (Chromium/Edge only for now) are observed over the DevTools
|
|
24
|
+
* protocol, without the extension's cooperation: the browser reports each
|
|
25
|
+
* extension file it compiles into the page (Debugger.scriptParsed) and each
|
|
26
|
+
* stylesheet it injects (CSS origin "injected"). That proves injection, not
|
|
27
|
+
* that the script then did its job.
|
|
19
28
|
*
|
|
20
29
|
* Limits, stated in every result: the popup is opened as a tab, not from the
|
|
21
30
|
* toolbar, and no page is interacted with — this proves the package boots
|
|
@@ -31,7 +40,8 @@ import JSZip from 'jszip';
|
|
|
31
40
|
import { InvalidInputError } from './expected-error.js';
|
|
32
41
|
import { findFirefox, launchFirefox } from './firefox-rdp.js';
|
|
33
42
|
import { isPlaceholderGeckoId, isValidGeckoId } from './gecko-id.js';
|
|
34
|
-
import { manifestGeckoId, manifestReferences } from './package.js';
|
|
43
|
+
import { collectFiles, manifestGeckoId, manifestReferences, reproducibleMtime } from './package.js';
|
|
44
|
+
import { ZipBuilder } from './zip.js';
|
|
35
45
|
export const SMOKE_ENGINES = ['chromium', 'edge', 'firefox'];
|
|
36
46
|
/** The store browser the evidence is recorded for. */
|
|
37
47
|
export const ENGINE_BROWSER = {
|
|
@@ -46,11 +56,19 @@ export function parseSmokeEngine(value) {
|
|
|
46
56
|
throw new InvalidInputError('--browser must be one of: chromium (alias chrome), edge, firefox');
|
|
47
57
|
return engine;
|
|
48
58
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
function limitsFor(engine, sites) {
|
|
60
|
+
return [
|
|
61
|
+
'The popup is opened as a regular tab, not from the toolbar button.',
|
|
62
|
+
'Pages are opened, not interacted with: this proves the package boots without errors, not that its features work.',
|
|
63
|
+
!sites.length
|
|
64
|
+
? 'Content scripts are not exercised (no --site given).'
|
|
65
|
+
: engine === 'firefox'
|
|
66
|
+
? 'Content scripts are not exercised: --site is not supported on Firefox yet (use chromium or edge).'
|
|
67
|
+
: 'Content scripts are checked in the top frame only: each declared js file was compiled into the page and ' +
|
|
68
|
+
'the declared CSS was injected (counted — the browser does not say which file a sheet came from). ' +
|
|
69
|
+
'Whether the script then did its job is not checked.',
|
|
70
|
+
];
|
|
71
|
+
}
|
|
54
72
|
/** The extension's own pages a user can open, as the manifest declares them. */
|
|
55
73
|
export function extensionPages(manifest) {
|
|
56
74
|
const pages = [];
|
|
@@ -68,6 +86,125 @@ export function extensionPages(manifest) {
|
|
|
68
86
|
add('sidebar', manifest.sidebar_action?.default_panel);
|
|
69
87
|
return pages;
|
|
70
88
|
}
|
|
89
|
+
// ── Content scripts ─────────────────────────────────────────────────────────
|
|
90
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
91
|
+
/** `*` = any run of characters (including `/`); `?` = one character where `question` is set (globs). */
|
|
92
|
+
const wildcard = (pattern, question) => new RegExp(`^${[...pattern].map((ch) => (ch === '*' ? '.*' : question && ch === '?' ? '.' : escapeRe(ch))).join('')}$`);
|
|
93
|
+
const DEFAULT_PORTS = { http: '80', https: '443', ws: '80', wss: '443' };
|
|
94
|
+
/**
|
|
95
|
+
* A manifest match pattern as the browser applies it to a page: `<all_urls>`;
|
|
96
|
+
* scheme `*` = http or https; host `*.example.com` = the host and its
|
|
97
|
+
* subdomains; no port = any port; the path glob covers path + query. A pattern
|
|
98
|
+
* the browser would reject matches nothing.
|
|
99
|
+
*/
|
|
100
|
+
export function matchesPattern(pattern, url) {
|
|
101
|
+
const scheme = url.protocol.replace(/:$/, '');
|
|
102
|
+
if (pattern === '<all_urls>')
|
|
103
|
+
return ['http', 'https', 'ws', 'wss', 'ftp', 'file'].includes(scheme);
|
|
104
|
+
const parts = /^(\*|[a-z][a-z0-9+.-]*):\/\/([^/]*)(\/.*)$/i.exec(pattern);
|
|
105
|
+
if (!parts)
|
|
106
|
+
return false;
|
|
107
|
+
const [, wantScheme, hostPort, pathGlob] = parts;
|
|
108
|
+
if (wantScheme === '*' ? scheme !== 'http' && scheme !== 'https' : wantScheme.toLowerCase() !== scheme)
|
|
109
|
+
return false;
|
|
110
|
+
if (scheme !== 'file') {
|
|
111
|
+
const hp = /^(\*|\*\.[^:*/]+|\[[^\]]+\]|[^:*/]+)(?::(\*|\d+))?$/.exec(hostPort);
|
|
112
|
+
if (!hp)
|
|
113
|
+
return false;
|
|
114
|
+
const host = hp[1].toLowerCase();
|
|
115
|
+
const hostname = url.hostname.toLowerCase();
|
|
116
|
+
const hostOk = host === '*' ||
|
|
117
|
+
hostname === host ||
|
|
118
|
+
(host.startsWith('*.') && (hostname === host.slice(2) || hostname.endsWith(host.slice(1))));
|
|
119
|
+
if (!hostOk)
|
|
120
|
+
return false;
|
|
121
|
+
if (hp[2] && hp[2] !== '*' && hp[2] !== (url.port || DEFAULT_PORTS[scheme]))
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return wildcard(pathGlob, false).test(url.pathname + url.search);
|
|
125
|
+
}
|
|
126
|
+
const asList = (value) => Array.isArray(value) ? value.filter((v) => typeof v === 'string' && !!v.trim()) : [];
|
|
127
|
+
const normalizePath = (p) => p.trim().replace(/^\.?\/+/, '');
|
|
128
|
+
/** The `content_scripts` entries the browser injects into a top-level page at `url`. */
|
|
129
|
+
export function contentScriptsFor(manifest, url) {
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = new URL(url);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
const entries = Array.isArray(manifest.content_scripts) ? manifest.content_scripts : [];
|
|
138
|
+
const result = [];
|
|
139
|
+
entries.forEach((entry, index) => {
|
|
140
|
+
const js = asList(entry?.js).map(normalizePath);
|
|
141
|
+
const css = asList(entry?.css).map(normalizePath);
|
|
142
|
+
if (!js.length && !css.length)
|
|
143
|
+
return;
|
|
144
|
+
if (!asList(entry.matches).some((p) => matchesPattern(p, parsed)))
|
|
145
|
+
return;
|
|
146
|
+
if (asList(entry.exclude_matches).some((p) => matchesPattern(p, parsed)))
|
|
147
|
+
return;
|
|
148
|
+
// Globs see the whole URL, #fragment included (checked in Chromium) — unlike the
|
|
149
|
+
// match pattern's path, which stops at the query.
|
|
150
|
+
const include = asList(entry.include_globs);
|
|
151
|
+
if (include.length && !include.some((g) => wildcard(g, true).test(parsed.href)))
|
|
152
|
+
return;
|
|
153
|
+
if (asList(entry.exclude_globs).some((g) => wildcard(g, true).test(parsed.href)))
|
|
154
|
+
return;
|
|
155
|
+
result.push({ index, js, css });
|
|
156
|
+
});
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
/** The checks one visited site yields: injection of every matching entry, then extension errors. */
|
|
160
|
+
export function siteChecks(manifest, observation) {
|
|
161
|
+
const { site } = observation;
|
|
162
|
+
const name = `content scripts inject on ${site}`;
|
|
163
|
+
if (observation.openError || !observation.finalUrl)
|
|
164
|
+
return [{ name, status: 'failed', detail: `did not open: ${observation.openError ?? 'unknown error'}` }];
|
|
165
|
+
const landed = observation.finalUrl !== site ? ` (landed on ${observation.finalUrl})` : '';
|
|
166
|
+
const entries = contentScriptsFor(manifest, observation.finalUrl);
|
|
167
|
+
if (!entries.length)
|
|
168
|
+
return [{ name, status: 'skipped', detail: `no content_scripts entry matches this URL${landed}` }];
|
|
169
|
+
const loaded = new Set(observation.scripts);
|
|
170
|
+
const cssDeclared = new Set(entries.flatMap((e) => e.css)).size;
|
|
171
|
+
let ok = true;
|
|
172
|
+
const parts = entries.map((entry) => {
|
|
173
|
+
const js = entry.js.map((file) => {
|
|
174
|
+
if (!loaded.has(file))
|
|
175
|
+
ok = false;
|
|
176
|
+
return `${file} ${loaded.has(file) ? 'ran' : 'did not run'}`;
|
|
177
|
+
});
|
|
178
|
+
return `content_scripts[${entry.index}]: ${js.length ? js.join(', ') : 'no js'}`;
|
|
179
|
+
});
|
|
180
|
+
if (cssDeclared) {
|
|
181
|
+
if (observation.injectedSheets < cssDeclared)
|
|
182
|
+
ok = false;
|
|
183
|
+
parts.push(`${Math.min(observation.injectedSheets, cssDeclared)}/${cssDeclared} declared stylesheet(s) injected`);
|
|
184
|
+
}
|
|
185
|
+
const errors = [...new Set(observation.errors)];
|
|
186
|
+
return [
|
|
187
|
+
{ name, status: ok ? 'passed' : 'failed', detail: `${parts.join(' · ')}${landed}` },
|
|
188
|
+
{
|
|
189
|
+
name: `no extension errors on ${site}`,
|
|
190
|
+
status: errors.length ? 'failed' : 'passed',
|
|
191
|
+
...(errors.length ? { detail: errors.slice(0, 5).join(' | ') } : {}),
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
/** A `--site` value: an absolute http(s) URL. */
|
|
196
|
+
export function parseSiteUrl(value) {
|
|
197
|
+
let url;
|
|
198
|
+
try {
|
|
199
|
+
url = new URL(value.trim());
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
throw new InvalidInputError(`--site ${value}: not an absolute URL`);
|
|
203
|
+
}
|
|
204
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
205
|
+
throw new InvalidInputError(`--site ${value}: only http(s) pages — content scripts do not run on file:// without an extra permission; serve local pages with a static server.`);
|
|
206
|
+
return url.href;
|
|
207
|
+
}
|
|
71
208
|
async function readPackage(bytes) {
|
|
72
209
|
let zip;
|
|
73
210
|
try {
|
|
@@ -91,6 +228,23 @@ async function readPackage(bytes) {
|
|
|
91
228
|
.map((f) => f.name));
|
|
92
229
|
return { manifest, paths, zip };
|
|
93
230
|
}
|
|
231
|
+
/** A directory (an unpacked build) rather than a package file. */
|
|
232
|
+
export function isExtensionDirectory(file) {
|
|
233
|
+
return !!fs.statSync(file, { throwIfNoEntry: false })?.isDirectory();
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The folder as `extenshi package` would read it, in a reproducible zip (the
|
|
237
|
+
* same stamp as package.ts), so the rest of the run — static checks,
|
|
238
|
+
* extraction, Firefox's install — treats it exactly like a package.
|
|
239
|
+
*/
|
|
240
|
+
function zipDirectory(dir) {
|
|
241
|
+
if (!fs.existsSync(path.join(dir, 'manifest.json')))
|
|
242
|
+
throw new InvalidInputError(`${dir} has no manifest.json — pass the extension's root folder, or a zip.`);
|
|
243
|
+
const writer = new ZipBuilder({ mtime: reproducibleMtime() });
|
|
244
|
+
for (const file of collectFiles(dir).files)
|
|
245
|
+
writer.addFile(file.path, file.bytes);
|
|
246
|
+
return Buffer.from(writer.toBytes());
|
|
247
|
+
}
|
|
94
248
|
/** Checks that need no browser: the files the manifest names exist, and Firefox has a real add-on ID. */
|
|
95
249
|
export function staticChecks(manifest, paths, engine) {
|
|
96
250
|
const missing = manifestReferences(manifest).filter((ref) => !paths.has(ref));
|
|
@@ -116,13 +270,22 @@ export function staticChecks(manifest, paths, engine) {
|
|
|
116
270
|
export async function runSmoke(options) {
|
|
117
271
|
const artifact = path.resolve(options.artifactPath);
|
|
118
272
|
const stat = fs.statSync(artifact, { throwIfNoEntry: false });
|
|
119
|
-
if (!stat?.isFile())
|
|
273
|
+
if (!stat?.isFile() && !stat?.isDirectory())
|
|
120
274
|
throw new InvalidInputError(`Package not found: ${options.artifactPath}`);
|
|
121
|
-
const
|
|
275
|
+
const sites = (options.sites ?? []).map(parseSiteUrl);
|
|
276
|
+
const source = stat?.isDirectory() ? 'directory' : 'zip';
|
|
277
|
+
const bytes = source === 'directory' ? zipDirectory(artifact) : fs.readFileSync(artifact);
|
|
122
278
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
123
279
|
const loaded = await readPackage(bytes);
|
|
124
280
|
const checks = staticChecks(loaded.manifest, loaded.paths, options.engine);
|
|
125
|
-
const base = {
|
|
281
|
+
const base = {
|
|
282
|
+
artifact,
|
|
283
|
+
source,
|
|
284
|
+
sha256,
|
|
285
|
+
browser: options.engine,
|
|
286
|
+
limits: limitsFor(options.engine, sites),
|
|
287
|
+
};
|
|
288
|
+
const run = { ...options, sites };
|
|
126
289
|
let engineChecks;
|
|
127
290
|
if (checks.some((c) => c.status === 'failed')) {
|
|
128
291
|
// A package that cannot load is reported as such, without a browser run
|
|
@@ -130,10 +293,10 @@ export async function runSmoke(options) {
|
|
|
130
293
|
engineChecks = { engine: { name: options.engine }, checks: [] };
|
|
131
294
|
}
|
|
132
295
|
else if (options.engine === 'firefox') {
|
|
133
|
-
engineChecks = await smokeFirefox(artifact, loaded.manifest,
|
|
296
|
+
engineChecks = await smokeFirefox(source === 'directory' ? bytes : artifact, loaded.manifest, run);
|
|
134
297
|
}
|
|
135
298
|
else {
|
|
136
|
-
engineChecks = await smokeChromium(loaded,
|
|
299
|
+
engineChecks = await smokeChromium(loaded, run);
|
|
137
300
|
}
|
|
138
301
|
const all = [...checks, ...engineChecks.checks];
|
|
139
302
|
return {
|
|
@@ -312,6 +475,9 @@ async function smokeChromium(loaded, options) {
|
|
|
312
475
|
});
|
|
313
476
|
await tab.close();
|
|
314
477
|
}
|
|
478
|
+
// Sites before the final error read, so content-script errors also land there.
|
|
479
|
+
for (const site of options.sites ?? [])
|
|
480
|
+
checks.push(...siteChecks(loaded.manifest, await observeSite(context, id, loaded.manifest, site, timeout)));
|
|
315
481
|
const final = (await info()).find((e) => e.id === id) ?? {};
|
|
316
482
|
const manifestProblems = [
|
|
317
483
|
...(final.manifestErrors ?? []).map((e) => String(e.message)),
|
|
@@ -336,8 +502,94 @@ async function smokeChromium(loaded, options) {
|
|
|
336
502
|
fs.rmSync(profile, { recursive: true, force: true });
|
|
337
503
|
}
|
|
338
504
|
}
|
|
505
|
+
/**
|
|
506
|
+
* Open `site` in a fresh tab with a DevTools session attached BEFORE the
|
|
507
|
+
* navigation, and record what the browser itself reports for the top frame:
|
|
508
|
+
* extension files compiled into the page (isolated world or MAIN), stylesheets
|
|
509
|
+
* injected for an extension, and errors raised by the extension's world, its
|
|
510
|
+
* files or its URLs. Nothing is asked of the extension, and its state is not
|
|
511
|
+
* touched.
|
|
512
|
+
*/
|
|
513
|
+
async function observeSite(context, id, manifest, site, timeout) {
|
|
514
|
+
const base = `chrome-extension://${id}/`;
|
|
515
|
+
const fromExtension = (url) => typeof url === 'string' && url.startsWith(base);
|
|
516
|
+
const observation = { site, scripts: [], injectedSheets: 0, errors: [] };
|
|
517
|
+
const tab = await context.newPage();
|
|
518
|
+
try {
|
|
519
|
+
const cdp = await context.newCDPSession(tab);
|
|
520
|
+
const top = (await cdp.send('Page.getFrameTree')).frameTree.frame.id;
|
|
521
|
+
const frameOf = new Map();
|
|
522
|
+
const extensionWorlds = new Set();
|
|
523
|
+
let scripts = new Set();
|
|
524
|
+
let sheets = new Set();
|
|
525
|
+
// A new document in the tab (redirect, client-side navigation) starts the count over.
|
|
526
|
+
cdp.on('Runtime.executionContextsCleared', () => {
|
|
527
|
+
scripts = new Set();
|
|
528
|
+
sheets = new Set();
|
|
529
|
+
});
|
|
530
|
+
cdp.on('Runtime.executionContextCreated', ({ context: world }) => {
|
|
531
|
+
frameOf.set(world.id, world.auxData?.frameId);
|
|
532
|
+
if (world.origin === base.slice(0, -1))
|
|
533
|
+
extensionWorlds.add(world.id);
|
|
534
|
+
});
|
|
535
|
+
cdp.on('Debugger.scriptParsed', (script) => {
|
|
536
|
+
const frame = script.executionContextAuxData?.frameId ?? frameOf.get(script.executionContextId);
|
|
537
|
+
if (frame === top && fromExtension(script.url))
|
|
538
|
+
scripts.add(script.url.slice(base.length).split(/[?#]/)[0]);
|
|
539
|
+
});
|
|
540
|
+
cdp.on('CSS.styleSheetAdded', ({ header }) => {
|
|
541
|
+
if (header.origin === 'injected' && header.frameId === top)
|
|
542
|
+
sheets.add(header.styleSheetId);
|
|
543
|
+
});
|
|
544
|
+
cdp.on('CSS.styleSheetRemoved', ({ styleSheetId }) => sheets.delete(styleSheetId));
|
|
545
|
+
const byExtension = (contextId, frames, url) => extensionWorlds.has(contextId) || fromExtension(url) || !!frames?.some((f) => fromExtension(f.url));
|
|
546
|
+
cdp.on('Runtime.exceptionThrown', ({ exceptionDetails: d }) => {
|
|
547
|
+
if (byExtension(d.executionContextId, d.stackTrace?.callFrames, d.url))
|
|
548
|
+
observation.errors.push(`uncaught: ${String(d.exception?.description ?? d.text).split('\n')[0]}`);
|
|
549
|
+
});
|
|
550
|
+
cdp.on('Runtime.consoleAPICalled', (call) => {
|
|
551
|
+
if (call.type === 'error' && byExtension(call.executionContextId, call.stackTrace?.callFrames))
|
|
552
|
+
observation.errors.push(`console.error: ${(call.args ?? []).map((a) => a.value ?? a.description ?? a.type).join(' ')}`);
|
|
553
|
+
});
|
|
554
|
+
// Browser-raised messages: a blocked web_accessible_resource, a failed load of an extension URL.
|
|
555
|
+
cdp.on('Log.entryAdded', ({ entry }) => {
|
|
556
|
+
if (entry.level === 'error' && (fromExtension(entry.url) || String(entry.text).includes(base)))
|
|
557
|
+
observation.errors.push(String(entry.text));
|
|
558
|
+
});
|
|
559
|
+
await cdp.send('Runtime.enable');
|
|
560
|
+
await cdp.send('Debugger.enable');
|
|
561
|
+
// A `debugger;` statement in the extension or the site must not stall the run.
|
|
562
|
+
await cdp.send('Debugger.setSkipAllPauses', { skip: true });
|
|
563
|
+
await cdp.send('DOM.enable');
|
|
564
|
+
await cdp.send('CSS.enable');
|
|
565
|
+
await cdp.send('Log.enable');
|
|
566
|
+
try {
|
|
567
|
+
await tab.goto(site, { waitUntil: 'load', timeout });
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
observation.openError = error.message.split('\n')[0];
|
|
571
|
+
return observation;
|
|
572
|
+
}
|
|
573
|
+
// document_idle scripts go in shortly after load: wait for what is expected, then for late errors.
|
|
574
|
+
const expected = contentScriptsFor(manifest, tab.url());
|
|
575
|
+
const js = expected.flatMap((e) => e.js);
|
|
576
|
+
const css = new Set(expected.flatMap((e) => e.css)).size;
|
|
577
|
+
if (expected.length)
|
|
578
|
+
await until(async () => js.every((f) => scripts.has(f)) && sheets.size >= css, Math.min(timeout, 5_000));
|
|
579
|
+
await sleep(Math.min(1_500, timeout));
|
|
580
|
+
observation.finalUrl = tab.url();
|
|
581
|
+
observation.scripts = [...scripts];
|
|
582
|
+
observation.injectedSheets = sheets.size;
|
|
583
|
+
return observation;
|
|
584
|
+
}
|
|
585
|
+
finally {
|
|
586
|
+
await tab.close().catch(() => { });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
339
589
|
// ── Firefox ─────────────────────────────────────────────────────────────────
|
|
340
|
-
async function smokeFirefox(
|
|
590
|
+
async function smokeFirefox(
|
|
591
|
+
/** The zip on disk, or the bytes of one built from a folder. */
|
|
592
|
+
artifact, manifest, options) {
|
|
341
593
|
const timeout = options.timeoutMs ?? 15_000;
|
|
342
594
|
const binary = findFirefox(options.firefoxBinary);
|
|
343
595
|
if (!binary)
|
|
@@ -345,12 +597,25 @@ async function smokeFirefox(artifact, manifest, options) {
|
|
|
345
597
|
const addonId = manifestGeckoId(manifest);
|
|
346
598
|
const uuid = randomUUID();
|
|
347
599
|
const base = `moz-extension://${uuid}/`;
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
600
|
+
// Firefox installs a zip file: give a folder's bytes one, removed afterwards.
|
|
601
|
+
const tmp = typeof artifact === 'string' ? null : fs.mkdtempSync(path.join(os.tmpdir(), 'extenshi-smoke-xpi-'));
|
|
602
|
+
const addonPath = tmp ? path.join(tmp, 'extension.zip') : artifact;
|
|
603
|
+
let firefox;
|
|
604
|
+
try {
|
|
605
|
+
if (tmp)
|
|
606
|
+
fs.writeFileSync(addonPath, artifact);
|
|
607
|
+
firefox = await launchFirefox({
|
|
608
|
+
binary,
|
|
609
|
+
uuids: { [addonId]: uuid },
|
|
610
|
+
headless: !options.headed,
|
|
611
|
+
timeoutMs: timeout * 2,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
if (tmp)
|
|
616
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
354
619
|
const checks = [];
|
|
355
620
|
const engine = { name: 'firefox-rdp', binary };
|
|
356
621
|
try {
|
|
@@ -387,7 +652,7 @@ async function smokeFirefox(artifact, manifest, options) {
|
|
|
387
652
|
if (typeof version === 'string')
|
|
388
653
|
engine.browserVersion = version;
|
|
389
654
|
}
|
|
390
|
-
const installed = await client.request(root.addonsActor, 'installTemporaryAddon', { addonPath
|
|
655
|
+
const installed = await client.request(root.addonsActor, 'installTemporaryAddon', { addonPath });
|
|
391
656
|
if (installed.error) {
|
|
392
657
|
checks.push({
|
|
393
658
|
name: 'temporary install accepted',
|
|
@@ -467,6 +732,14 @@ async function smokeFirefox(artifact, manifest, options) {
|
|
|
467
732
|
detail: problems.length ? `${page.path}: ${problems.slice(0, 5).join(' | ')}` : page.path,
|
|
468
733
|
});
|
|
469
734
|
}
|
|
735
|
+
// Content scripts live in the web page's process, which this add-on watcher
|
|
736
|
+
// does not see; observing them needs the tab's own targets — not built yet.
|
|
737
|
+
for (const site of options.sites ?? [])
|
|
738
|
+
checks.push({
|
|
739
|
+
name: `content scripts inject on ${site}`,
|
|
740
|
+
status: 'skipped',
|
|
741
|
+
detail: 'not supported on Firefox yet — run --site with --browser chromium or edge',
|
|
742
|
+
});
|
|
470
743
|
await sleep(250);
|
|
471
744
|
const runtime = errors.map((e) => `${e.text}${e.url ? ` (${e.url.replace(base, '')})` : ''}`);
|
|
472
745
|
checks.push({
|
|
@@ -478,12 +751,15 @@ async function smokeFirefox(artifact, manifest, options) {
|
|
|
478
751
|
}
|
|
479
752
|
finally {
|
|
480
753
|
await firefox.close();
|
|
754
|
+
if (tmp)
|
|
755
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
481
756
|
}
|
|
482
757
|
}
|
|
483
758
|
export function renderSmoke(result) {
|
|
484
759
|
const mark = { passed: '✓', failed: '✗', skipped: '–' };
|
|
760
|
+
const unpacked = result.source === 'directory' ? ' (unpacked folder; sha256 of the zip built from it)' : '';
|
|
485
761
|
return [
|
|
486
|
-
`${result.browser} smoke — ${result.status.toUpperCase()} ${result.sha256} ${result.artifact}`,
|
|
762
|
+
`${result.browser} smoke — ${result.status.toUpperCase()} ${result.sha256} ${result.artifact}${unpacked}`,
|
|
487
763
|
...result.checks.map((c) => ` ${mark[c.status]} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`),
|
|
488
764
|
...result.limits.map((l) => ` note: ${l}`),
|
|
489
765
|
].join('\n');
|