wip major changes

This commit is contained in:
Nick Sweeting
2025-12-24 20:09:51 -08:00
parent c1335fed37
commit 1915333b81
450 changed files with 35814 additions and 19015 deletions

View File

@@ -0,0 +1,483 @@
#!/usr/bin/env node
/**
* Chrome Extension Management Utilities
*
* Handles downloading, installing, and managing Chrome extensions for browser automation.
* Ported from the TypeScript implementation in archivebox.ts
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { exec } = require('child_process');
const { promisify } = require('util');
const { Readable } = require('stream');
const { finished } = require('stream/promises');
const execAsync = promisify(exec);
// Try to import unzipper, fallback to system unzip if not available
let unzip = null;
try {
const unzipper = require('unzipper');
unzip = async (sourcePath, destPath) => {
const stream = fs.createReadStream(sourcePath).pipe(unzipper.Extract({ path: destPath }));
return stream.promise();
};
} catch (err) {
// Will use system unzip command as fallback
}
/**
* Compute the extension ID from the unpacked path.
* Chrome uses a SHA256 hash of the unpacked extension directory path to compute a dynamic id.
*
* @param {string} unpacked_path - Path to the unpacked extension directory
* @returns {string} - 32-character extension ID
*/
function getExtensionId(unpacked_path) {
// Chrome uses a SHA256 hash of the unpacked extension directory path
const hash = crypto.createHash('sha256');
hash.update(Buffer.from(unpacked_path, 'utf-8'));
// Convert first 32 hex chars to characters in the range 'a'-'p'
const detected_extension_id = Array.from(hash.digest('hex'))
.slice(0, 32)
.map(i => String.fromCharCode(parseInt(i, 16) + 'a'.charCodeAt(0)))
.join('');
return detected_extension_id;
}
/**
* Download and install a Chrome extension from the Chrome Web Store.
*
* @param {Object} extension - Extension metadata object
* @param {string} extension.webstore_id - Chrome Web Store extension ID
* @param {string} extension.name - Human-readable extension name
* @param {string} extension.crx_url - URL to download the CRX file
* @param {string} extension.crx_path - Local path to save the CRX file
* @param {string} extension.unpacked_path - Path to extract the extension
* @returns {Promise<boolean>} - True if installation succeeded
*/
async function installExtension(extension) {
const manifest_path = path.join(extension.unpacked_path, 'manifest.json');
// Download CRX file if not already downloaded
if (!fs.existsSync(manifest_path) && !fs.existsSync(extension.crx_path)) {
console.log(`[🛠️] Downloading missing extension ${extension.name} ${extension.webstore_id} -> ${extension.crx_path}`);
try {
// Ensure parent directory exists
const crxDir = path.dirname(extension.crx_path);
if (!fs.existsSync(crxDir)) {
fs.mkdirSync(crxDir, { recursive: true });
}
// Download CRX file from Chrome Web Store
const response = await fetch(extension.crx_url);
if (!response.ok) {
console.warn(`[⚠️] Failed to download extension ${extension.name}: HTTP ${response.status}`);
return false;
}
if (response.body) {
const crx_file = fs.createWriteStream(extension.crx_path);
const crx_stream = Readable.fromWeb(response.body);
await finished(crx_stream.pipe(crx_file));
} else {
console.warn(`[⚠️] Failed to download extension ${extension.name}: No response body`);
return false;
}
} catch (err) {
console.error(`[❌] Failed to download extension ${extension.name}:`, err);
return false;
}
}
// Unzip CRX file to unpacked_path
await fs.promises.mkdir(extension.unpacked_path, { recursive: true });
try {
// Try system unzip command first
await execAsync(`/usr/bin/unzip -o ${extension.crx_path} -d ${extension.unpacked_path}`);
} catch (err1) {
if (unzip) {
// Fallback to unzipper library
try {
await unzip(extension.crx_path, extension.unpacked_path);
} catch (err2) {
console.error(`[❌] Failed to unzip ${extension.crx_path}:`, err1.message);
return false;
}
} else {
console.error(`[❌] Failed to unzip ${extension.crx_path}:`, err1.message);
return false;
}
}
if (!fs.existsSync(manifest_path)) {
console.error(`[❌] Failed to install ${extension.crx_path}: could not find manifest.json in unpacked_path`);
return false;
}
return true;
}
/**
* Load or install a Chrome extension, computing all metadata.
*
* @param {Object} ext - Partial extension metadata (at minimum: webstore_id or unpacked_path)
* @param {string} [ext.webstore_id] - Chrome Web Store extension ID
* @param {string} [ext.name] - Human-readable extension name
* @param {string} [ext.unpacked_path] - Path to unpacked extension
* @param {string} [extensions_dir] - Directory to store extensions
* @returns {Promise<Object>} - Complete extension metadata object
*/
async function loadOrInstallExtension(ext, extensions_dir = null) {
if (!(ext.webstore_id || ext.unpacked_path)) {
throw new Error('Extension must have either {webstore_id} or {unpacked_path}');
}
// Determine extensions directory
const EXTENSIONS_DIR = extensions_dir || process.env.CHROME_EXTENSIONS_DIR || './data/chrome_extensions';
// Set statically computable extension metadata
ext.webstore_id = ext.webstore_id || ext.id;
ext.name = ext.name || ext.webstore_id;
ext.webstore_url = ext.webstore_url || `https://chromewebstore.google.com/detail/${ext.webstore_id}`;
ext.crx_url = ext.crx_url || `https://clients2.google.com/service/update2/crx?response=redirect&prodversion=1230&acceptformat=crx3&x=id%3D${ext.webstore_id}%26uc`;
ext.crx_path = ext.crx_path || path.join(EXTENSIONS_DIR, `${ext.webstore_id}__${ext.name}.crx`);
ext.unpacked_path = ext.unpacked_path || path.join(EXTENSIONS_DIR, `${ext.webstore_id}__${ext.name}`);
const manifest_path = path.join(ext.unpacked_path, 'manifest.json');
ext.read_manifest = () => JSON.parse(fs.readFileSync(manifest_path, 'utf-8'));
ext.read_version = () => fs.existsSync(manifest_path) && ext.read_manifest()?.version || null;
// If extension is not installed, download and unpack it
if (!ext.read_version()) {
await installExtension(ext);
}
// Autodetect ID from filesystem path (unpacked extensions don't have stable IDs)
ext.id = getExtensionId(ext.unpacked_path);
ext.version = ext.read_version();
if (!ext.version) {
console.warn(`[❌] Unable to detect ID and version of installed extension ${ext.unpacked_path}`);
} else {
console.log(`[] Installed extension ${ext.name} (${ext.version})... ${ext.unpacked_path}`);
}
return ext;
}
/**
* Check if a Puppeteer target is an extension background page/service worker.
*
* @param {Object} target - Puppeteer target object
* @returns {Promise<Object>} - Object with target_is_bg, extension_id, manifest_version, etc.
*/
async function isTargetExtension(target) {
let target_type;
let target_ctx;
let target_url;
try {
target_type = target.type();
target_ctx = (await target.worker()) || (await target.page()) || null;
target_url = target.url() || target_ctx?.url() || null;
} catch (err) {
if (String(err).includes('No target with given id found')) {
// Target closed during check, ignore harmless race condition
target_type = 'closed';
target_ctx = null;
target_url = 'about:closed';
} else {
throw err;
}
}
// Check if this is an extension background page or service worker
const is_chrome_extension = target_url?.startsWith('chrome-extension://');
const is_background_page = target_type === 'background_page';
const is_service_worker = target_type === 'service_worker';
const target_is_bg = is_chrome_extension && (is_background_page || is_service_worker);
let extension_id = null;
let manifest_version = null;
const target_is_extension = is_chrome_extension || target_is_bg;
if (target_is_extension) {
try {
extension_id = target_url?.split('://')[1]?.split('/')[0] || null;
if (target_ctx) {
const manifest = await target_ctx.evaluate(() => chrome.runtime.getManifest());
manifest_version = manifest?.manifest_version || null;
}
} catch (err) {
// Failed to get extension metadata
}
}
return {
target_is_extension,
target_is_bg,
target_type,
target_ctx,
target_url,
extension_id,
manifest_version,
};
}
/**
* Load extension metadata and connection handlers from a browser target.
*
* @param {Array} extensions - Array of extension metadata objects to update
* @param {Object} target - Puppeteer target object
* @returns {Promise<Object|null>} - Updated extension object or null if not an extension
*/
async function loadExtensionFromTarget(extensions, target) {
const {
target_is_bg,
target_is_extension,
target_type,
target_ctx,
target_url,
extension_id,
manifest_version,
} = await isTargetExtension(target);
if (!(target_is_bg && extension_id && target_ctx)) {
return null;
}
// Find matching extension in our list
const extension = extensions.find(ext => ext.id === extension_id);
if (!extension) {
console.warn(`[⚠️] Found loaded extension ${extension_id} that's not in CHROME_EXTENSIONS list`);
return null;
}
// Load manifest from the extension context
let manifest = null;
try {
manifest = await target_ctx.evaluate(() => chrome.runtime.getManifest());
} catch (err) {
console.error(`[❌] Failed to read manifest for extension ${extension_id}:`, err);
return null;
}
// Create dispatch methods for communicating with the extension
const new_extension = {
...extension,
target,
target_type,
target_url,
manifest,
manifest_version,
// Trigger extension toolbar button click
dispatchAction: async (tab) => {
return await target_ctx.evaluate((tabId) => {
return new Promise((resolve) => {
chrome.action.onClicked.addListener((tab) => {
resolve({ success: true, tab });
});
chrome.action.openPopup();
});
}, tab?.id || null);
},
// Send message to extension
dispatchMessage: async (message, options = {}) => {
return await target_ctx.evaluate((msg, opts) => {
return new Promise((resolve) => {
chrome.runtime.sendMessage(msg, opts, (response) => {
resolve(response);
});
});
}, message, options);
},
// Trigger extension command (keyboard shortcut)
dispatchCommand: async (command) => {
return await target_ctx.evaluate((cmd) => {
return new Promise((resolve) => {
chrome.commands.onCommand.addListener((receivedCommand) => {
if (receivedCommand === cmd) {
resolve({ success: true, command: receivedCommand });
}
});
// Note: Actually triggering commands programmatically is not directly supported
// This would need to be done via CDP or keyboard simulation
});
}, command);
},
};
// Update the extension in the array
Object.assign(extension, new_extension);
console.log(`[🔌] Connected to extension ${extension.name} (${extension.version})`);
return new_extension;
}
/**
* Install all extensions in the list if not already installed.
*
* @param {Array} extensions - Array of extension metadata objects
* @param {string} [extensions_dir] - Directory to store extensions
* @returns {Promise<Array>} - Array of installed extension objects
*/
async function installAllExtensions(extensions, extensions_dir = null) {
console.log(`[⚙️] Installing ${extensions.length} chrome extensions...`);
for (const extension of extensions) {
await loadOrInstallExtension(extension, extensions_dir);
}
return extensions;
}
/**
* Load and connect to all extensions from a running browser.
*
* @param {Object} browser - Puppeteer browser instance
* @param {Array} extensions - Array of extension metadata objects
* @returns {Promise<Array>} - Array of loaded extension objects with connection handlers
*/
async function loadAllExtensionsFromBrowser(browser, extensions) {
console.log(`[⚙️] Loading ${extensions.length} chrome extensions from browser...`);
// Find loaded extensions at runtime by examining browser targets
for (const target of browser.targets()) {
await loadExtensionFromTarget(extensions, target);
}
return extensions;
}
/**
* Load extension manifest.json file
*
* @param {string} unpacked_path - Path to unpacked extension directory
* @returns {object|null} - Parsed manifest object or null if not found/invalid
*/
function loadExtensionManifest(unpacked_path) {
const manifest_path = path.join(unpacked_path, 'manifest.json');
if (!fs.existsSync(manifest_path)) {
return null;
}
try {
const manifest_content = fs.readFileSync(manifest_path, 'utf-8');
return JSON.parse(manifest_content);
} catch (error) {
// Invalid JSON or read error
return null;
}
}
/**
* Generate Chrome launch arguments for loading extensions.
*
* @param {Array} extensions - Array of extension metadata objects
* @returns {Array<string>} - Chrome CLI arguments for loading extensions
*/
function getExtensionLaunchArgs(extensions) {
if (!extensions || extensions.length === 0) {
return [];
}
// Filter out extensions without unpacked_path first
const validExtensions = extensions.filter(ext => ext.unpacked_path);
const unpacked_paths = validExtensions.map(ext => ext.unpacked_path);
const webstore_ids = validExtensions.map(ext => ext.webstore_id || ext.id);
return [
`--load-extension=${unpacked_paths.join(',')}`,
`--allowlisted-extension-id=${webstore_ids.join(',')}`,
'--allow-legacy-extension-manifests',
'--disable-extensions-auto-update',
];
}
// Export all functions
module.exports = {
getExtensionId,
loadExtensionManifest,
installExtension,
loadOrInstallExtension,
isTargetExtension,
loadExtensionFromTarget,
installAllExtensions,
loadAllExtensionsFromBrowser,
getExtensionLaunchArgs,
};
// CLI usage
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length === 0) {
console.log('Usage: chrome_extension_utils.js <command> [args...]');
console.log('');
console.log('Commands:');
console.log(' getExtensionId <path>');
console.log(' loadExtensionManifest <path>');
console.log(' getExtensionLaunchArgs <extensions_json>');
console.log(' loadOrInstallExtension <webstore_id> <name> [extensions_dir]');
process.exit(1);
}
const [command, ...commandArgs] = args;
(async () => {
try {
switch (command) {
case 'getExtensionId': {
const [unpacked_path] = commandArgs;
const id = getExtensionId(unpacked_path);
console.log(id);
break;
}
case 'loadExtensionManifest': {
const [unpacked_path] = commandArgs;
const manifest = loadExtensionManifest(unpacked_path);
console.log(JSON.stringify(manifest));
break;
}
case 'getExtensionLaunchArgs': {
const [extensions_json] = commandArgs;
const extensions = JSON.parse(extensions_json);
const args = getExtensionLaunchArgs(extensions);
console.log(JSON.stringify(args));
break;
}
case 'loadOrInstallExtension': {
const [webstore_id, name, extensions_dir] = commandArgs;
const ext = await loadOrInstallExtension({ webstore_id, name }, extensions_dir);
console.log(JSON.stringify(ext, null, 2));
break;
}
default:
console.error(`Unknown command: ${command}`);
process.exit(1);
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
})();
}

View File

@@ -0,0 +1,329 @@
/**
* Unit tests for chrome_extension_utils.js
*
* Run with: npm test
* Or: node --test tests/test_chrome_extension_utils.js
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { describe, it, before, after, beforeEach, afterEach } = require('node:test');
// Import module under test
const extensionUtils = require('../chrome_extension_utils.js');
// Test fixtures
const TEST_DIR = path.join(__dirname, '.test_fixtures');
const TEST_EXTENSIONS_DIR = path.join(TEST_DIR, 'chrome_extensions');
describe('chrome_extension_utils', () => {
before(() => {
// Create test directory
if (!fs.existsSync(TEST_DIR)) {
fs.mkdirSync(TEST_DIR, { recursive: true });
}
});
after(() => {
// Cleanup test directory
if (fs.existsSync(TEST_DIR)) {
fs.rmSync(TEST_DIR, { recursive: true, force: true });
}
});
describe('getExtensionId', () => {
it('should compute extension ID from path', () => {
const testPath = '/path/to/extension';
const extensionId = extensionUtils.getExtensionId(testPath);
assert.strictEqual(typeof extensionId, 'string');
assert.strictEqual(extensionId.length, 32);
// Should only contain lowercase letters a-p
assert.match(extensionId, /^[a-p]+$/);
});
it('should compute ID even for non-existent paths', () => {
const testPath = '/nonexistent/path';
const extensionId = extensionUtils.getExtensionId(testPath);
// Should still compute an ID from the path string
assert.strictEqual(typeof extensionId, 'string');
assert.strictEqual(extensionId.length, 32);
assert.match(extensionId, /^[a-p]+$/);
});
it('should return consistent ID for same path', () => {
const testPath = '/path/to/extension';
const id1 = extensionUtils.getExtensionId(testPath);
const id2 = extensionUtils.getExtensionId(testPath);
assert.strictEqual(id1, id2);
});
it('should return different IDs for different paths', () => {
const path1 = '/path/to/extension1';
const path2 = '/path/to/extension2';
const id1 = extensionUtils.getExtensionId(path1);
const id2 = extensionUtils.getExtensionId(path2);
assert.notStrictEqual(id1, id2);
});
});
describe('loadExtensionManifest', () => {
beforeEach(() => {
// Create test extension directory with manifest
const testExtDir = path.join(TEST_DIR, 'test_extension');
fs.mkdirSync(testExtDir, { recursive: true });
const manifest = {
manifest_version: 3,
name: "Test Extension",
version: "1.0.0"
};
fs.writeFileSync(
path.join(testExtDir, 'manifest.json'),
JSON.stringify(manifest)
);
});
afterEach(() => {
// Cleanup test extension
const testExtDir = path.join(TEST_DIR, 'test_extension');
if (fs.existsSync(testExtDir)) {
fs.rmSync(testExtDir, { recursive: true });
}
});
it('should load valid manifest.json', () => {
const testExtDir = path.join(TEST_DIR, 'test_extension');
const manifest = extensionUtils.loadExtensionManifest(testExtDir);
assert.notStrictEqual(manifest, null);
assert.strictEqual(manifest.manifest_version, 3);
assert.strictEqual(manifest.name, "Test Extension");
assert.strictEqual(manifest.version, "1.0.0");
});
it('should return null for missing manifest', () => {
const nonExistentDir = path.join(TEST_DIR, 'nonexistent');
const manifest = extensionUtils.loadExtensionManifest(nonExistentDir);
assert.strictEqual(manifest, null);
});
it('should handle invalid JSON gracefully', () => {
const testExtDir = path.join(TEST_DIR, 'invalid_extension');
fs.mkdirSync(testExtDir, { recursive: true });
// Write invalid JSON
fs.writeFileSync(
path.join(testExtDir, 'manifest.json'),
'invalid json content'
);
const manifest = extensionUtils.loadExtensionManifest(testExtDir);
assert.strictEqual(manifest, null);
// Cleanup
fs.rmSync(testExtDir, { recursive: true });
});
});
describe('getExtensionLaunchArgs', () => {
it('should return empty array for no extensions', () => {
const args = extensionUtils.getExtensionLaunchArgs([]);
assert.deepStrictEqual(args, []);
});
it('should generate correct launch args for single extension', () => {
const extensions = [{
webstore_id: 'abcd1234',
unpacked_path: '/path/to/extension'
}];
const args = extensionUtils.getExtensionLaunchArgs(extensions);
assert.strictEqual(args.length, 4);
assert.strictEqual(args[0], '--load-extension=/path/to/extension');
assert.strictEqual(args[1], '--allowlisted-extension-id=abcd1234');
assert.strictEqual(args[2], '--allow-legacy-extension-manifests');
assert.strictEqual(args[3], '--disable-extensions-auto-update');
});
it('should generate correct launch args for multiple extensions', () => {
const extensions = [
{ webstore_id: 'ext1', unpacked_path: '/path/ext1' },
{ webstore_id: 'ext2', unpacked_path: '/path/ext2' },
{ webstore_id: 'ext3', unpacked_path: '/path/ext3' }
];
const args = extensionUtils.getExtensionLaunchArgs(extensions);
assert.strictEqual(args.length, 4);
assert.strictEqual(args[0], '--load-extension=/path/ext1,/path/ext2,/path/ext3');
assert.strictEqual(args[1], '--allowlisted-extension-id=ext1,ext2,ext3');
});
it('should handle extensions with id instead of webstore_id', () => {
const extensions = [{
id: 'computed_id',
unpacked_path: '/path/to/extension'
}];
const args = extensionUtils.getExtensionLaunchArgs(extensions);
assert.strictEqual(args[1], '--allowlisted-extension-id=computed_id');
});
it('should filter out extensions without paths', () => {
const extensions = [
{ webstore_id: 'ext1', unpacked_path: '/path/ext1' },
{ webstore_id: 'ext2', unpacked_path: null },
{ webstore_id: 'ext3', unpacked_path: '/path/ext3' }
];
const args = extensionUtils.getExtensionLaunchArgs(extensions);
assert.strictEqual(args[0], '--load-extension=/path/ext1,/path/ext3');
assert.strictEqual(args[1], '--allowlisted-extension-id=ext1,ext3');
});
});
describe('loadOrInstallExtension', () => {
beforeEach(() => {
// Create test extensions directory
if (!fs.existsSync(TEST_EXTENSIONS_DIR)) {
fs.mkdirSync(TEST_EXTENSIONS_DIR, { recursive: true });
}
});
afterEach(() => {
// Cleanup test extensions directory
if (fs.existsSync(TEST_EXTENSIONS_DIR)) {
fs.rmSync(TEST_EXTENSIONS_DIR, { recursive: true });
}
});
it('should throw error if neither webstore_id nor unpacked_path provided', async () => {
await assert.rejects(
async () => {
await extensionUtils.loadOrInstallExtension({}, TEST_EXTENSIONS_DIR);
},
/Extension must have either/
);
});
it('should set correct default values for extension metadata', async () => {
const input = {
webstore_id: 'test123',
name: 'test_extension'
};
// Mock the installation to avoid actual download
const originalInstall = extensionUtils.installExtension;
extensionUtils.installExtension = async () => {
// Create fake manifest
const extDir = path.join(TEST_EXTENSIONS_DIR, 'test123__test_extension');
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, 'manifest.json'),
JSON.stringify({ version: '1.0.0' })
);
return true;
};
const ext = await extensionUtils.loadOrInstallExtension(input, TEST_EXTENSIONS_DIR);
// Restore original
extensionUtils.installExtension = originalInstall;
assert.strictEqual(ext.webstore_id, 'test123');
assert.strictEqual(ext.name, 'test_extension');
assert.ok(ext.webstore_url.includes(ext.webstore_id));
assert.ok(ext.crx_url.includes(ext.webstore_id));
assert.ok(ext.crx_path.includes('test123__test_extension.crx'));
assert.ok(ext.unpacked_path.includes('test123__test_extension'));
});
it('should detect version from manifest after installation', async () => {
const input = {
webstore_id: 'test456',
name: 'versioned_extension'
};
// Create pre-installed extension
const extDir = path.join(TEST_EXTENSIONS_DIR, 'test456__versioned_extension');
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, 'manifest.json'),
JSON.stringify({
manifest_version: 3,
name: "Versioned Extension",
version: "2.5.1"
})
);
const ext = await extensionUtils.loadOrInstallExtension(input, TEST_EXTENSIONS_DIR);
assert.strictEqual(ext.version, '2.5.1');
});
});
describe('isTargetExtension', () => {
it('should identify extension targets by URL', async () => {
// Mock Puppeteer target
const mockTarget = {
type: () => 'service_worker',
url: () => 'chrome-extension://abcdefgh/background.js',
worker: async () => null,
page: async () => null
};
const result = await extensionUtils.isTargetExtension(mockTarget);
assert.strictEqual(result.target_is_extension, true);
assert.strictEqual(result.target_is_bg, true);
assert.strictEqual(result.extension_id, 'abcdefgh');
});
it('should not identify non-extension targets', async () => {
const mockTarget = {
type: () => 'page',
url: () => 'https://example.com',
worker: async () => null,
page: async () => null
};
const result = await extensionUtils.isTargetExtension(mockTarget);
assert.strictEqual(result.target_is_extension, false);
assert.strictEqual(result.target_is_bg, false);
assert.strictEqual(result.extension_id, null);
});
it('should handle closed targets gracefully', async () => {
const mockTarget = {
type: () => { throw new Error('No target with given id found'); },
url: () => { throw new Error('No target with given id found'); },
worker: async () => { throw new Error('No target with given id found'); },
page: async () => { throw new Error('No target with given id found'); }
};
const result = await extensionUtils.isTargetExtension(mockTarget);
assert.strictEqual(result.target_type, 'closed');
assert.strictEqual(result.target_url, 'about:closed');
});
});
});
// Run tests if executed directly
if (require.main === module) {
console.log('Run tests with: npm test');
console.log('Or: node --test tests/test_chrome_extension_utils.js');
}

View File

@@ -0,0 +1,224 @@
"""
Unit tests for chrome_extension_utils.js
Tests invoke the script as an external process and verify outputs/side effects.
"""
import json
import subprocess
import tempfile
from pathlib import Path
import pytest
SCRIPT_PATH = Path(__file__).parent.parent / "chrome_extension_utils.js"
def test_script_exists():
"""Verify the script file exists and is executable via node"""
assert SCRIPT_PATH.exists(), f"Script not found: {SCRIPT_PATH}"
def test_get_extension_id():
"""Test extension ID computation from path"""
with tempfile.TemporaryDirectory() as tmpdir:
test_path = "/path/to/extension"
# Run script with test path
result = subprocess.run(
["node", str(SCRIPT_PATH), "getExtensionId", test_path],
capture_output=True,
text=True
)
assert result.returncode == 0, f"Script failed: {result.stderr}"
extension_id = result.stdout.strip()
# Should return 32-character ID with only letters a-p
assert len(extension_id) == 32
assert all(c in 'abcdefghijklmnop' for c in extension_id)
def test_get_extension_id_consistency():
"""Test that same path produces same ID"""
test_path = "/path/to/extension"
result1 = subprocess.run(
["node", str(SCRIPT_PATH), "getExtensionId", test_path],
capture_output=True,
text=True
)
result2 = subprocess.run(
["node", str(SCRIPT_PATH), "getExtensionId", test_path],
capture_output=True,
text=True
)
assert result1.returncode == 0
assert result2.returncode == 0
assert result1.stdout.strip() == result2.stdout.strip()
def test_get_extension_id_different_paths():
"""Test that different paths produce different IDs"""
result1 = subprocess.run(
["node", str(SCRIPT_PATH), "getExtensionId", "/path1"],
capture_output=True,
text=True
)
result2 = subprocess.run(
["node", str(SCRIPT_PATH), "getExtensionId", "/path2"],
capture_output=True,
text=True
)
assert result1.returncode == 0
assert result2.returncode == 0
assert result1.stdout.strip() != result2.stdout.strip()
def test_load_extension_manifest():
"""Test loading extension manifest.json"""
with tempfile.TemporaryDirectory() as tmpdir:
ext_dir = Path(tmpdir) / "test_extension"
ext_dir.mkdir()
# Create manifest
manifest = {
"manifest_version": 3,
"name": "Test Extension",
"version": "1.0.0"
}
(ext_dir / "manifest.json").write_text(json.dumps(manifest))
# Load manifest via script
result = subprocess.run(
["node", str(SCRIPT_PATH), "loadExtensionManifest", str(ext_dir)],
capture_output=True,
text=True
)
assert result.returncode == 0
loaded = json.loads(result.stdout)
assert loaded["manifest_version"] == 3
assert loaded["name"] == "Test Extension"
assert loaded["version"] == "1.0.0"
def test_load_extension_manifest_missing():
"""Test loading manifest from non-existent directory"""
with tempfile.TemporaryDirectory() as tmpdir:
nonexistent = Path(tmpdir) / "nonexistent"
result = subprocess.run(
["node", str(SCRIPT_PATH), "loadExtensionManifest", str(nonexistent)],
capture_output=True,
text=True
)
# Should return null/empty for missing manifest
assert result.returncode == 0
assert result.stdout.strip() in ("null", "")
def test_load_extension_manifest_invalid_json():
"""Test handling of invalid JSON in manifest"""
with tempfile.TemporaryDirectory() as tmpdir:
ext_dir = Path(tmpdir) / "test_extension"
ext_dir.mkdir()
# Write invalid JSON
(ext_dir / "manifest.json").write_text("invalid json content")
result = subprocess.run(
["node", str(SCRIPT_PATH), "loadExtensionManifest", str(ext_dir)],
capture_output=True,
text=True
)
# Should handle gracefully
assert result.returncode == 0
assert result.stdout.strip() in ("null", "")
def test_get_extension_launch_args_empty():
"""Test launch args with no extensions"""
result = subprocess.run(
["node", str(SCRIPT_PATH), "getExtensionLaunchArgs", "[]"],
capture_output=True,
text=True
)
assert result.returncode == 0
args = json.loads(result.stdout)
assert args == []
def test_get_extension_launch_args_single():
"""Test launch args with single extension"""
extensions = [{
"webstore_id": "abcd1234",
"unpacked_path": "/path/to/extension"
}]
result = subprocess.run(
["node", str(SCRIPT_PATH), "getExtensionLaunchArgs", json.dumps(extensions)],
capture_output=True,
text=True
)
assert result.returncode == 0
args = json.loads(result.stdout)
assert len(args) == 4
assert args[0] == "--load-extension=/path/to/extension"
assert args[1] == "--allowlisted-extension-id=abcd1234"
assert args[2] == "--allow-legacy-extension-manifests"
assert args[3] == "--disable-extensions-auto-update"
def test_get_extension_launch_args_multiple():
"""Test launch args with multiple extensions"""
extensions = [
{"webstore_id": "ext1", "unpacked_path": "/path/ext1"},
{"webstore_id": "ext2", "unpacked_path": "/path/ext2"},
{"webstore_id": "ext3", "unpacked_path": "/path/ext3"}
]
result = subprocess.run(
["node", str(SCRIPT_PATH), "getExtensionLaunchArgs", json.dumps(extensions)],
capture_output=True,
text=True
)
assert result.returncode == 0
args = json.loads(result.stdout)
assert args[0] == "--load-extension=/path/ext1,/path/ext2,/path/ext3"
assert args[1] == "--allowlisted-extension-id=ext1,ext2,ext3"
def test_get_extension_launch_args_filter_null_paths():
"""Test that extensions without paths are filtered out"""
extensions = [
{"webstore_id": "ext1", "unpacked_path": "/path/ext1"},
{"webstore_id": "ext2", "unpacked_path": None},
{"webstore_id": "ext3", "unpacked_path": "/path/ext3"}
]
result = subprocess.run(
["node", str(SCRIPT_PATH), "getExtensionLaunchArgs", json.dumps(extensions)],
capture_output=True,
text=True
)
assert result.returncode == 0
args = json.loads(result.stdout)
assert args[0] == "--load-extension=/path/ext1,/path/ext3"
assert args[1] == "--allowlisted-extension-id=ext1,ext3"