Testing Browser Extensions Before Chrome Web Store Submission
Getting your browser extension rejected from the Chrome Web Store is painful. Google's review can take days to weeks. If your extension gets rejected for a fixable issue — a permissions problem, a policy violation, or a content security issue — you're back to the end of the queue.
The Chrome Web Store has approximately 2,000+ rejected submissions per day, many for avoidable reasons. A thorough pre-submission testing process catches the most common rejection causes before you submit.
Chrome Web Store Rejection Reasons
Understanding why extensions get rejected helps you know what to test for:
Permissions violations (most common): Requesting permissions broader than what the extension actually uses. If your extension requests "<all_urls>" but only needs to run on specific domains, that's a rejection.
Deceptive description/screenshots: The extension's Store listing doesn't accurately match what the extension does.
Remote code execution: Loading JavaScript from external servers (eval(), dynamic Function(), external scripts). Manifest V3 bans most of these patterns.
Privacy policy missing: Any extension that handles user data needs a privacy policy linked in the Store listing.
User data policy violations: Extensions that collect, transmit, or share user data must disclose this prominently and comply with Google's User Data Policy.
Malicious code: Obvious, but includes hidden tracking, ad injection, and behavior that misrepresents what the extension does.
Broken functionality: Extensions that crash, show errors, or don't work as described.
Pre-Submission Testing Checklist
1. Permissions Audit
Audit every permission against actual usage:
// scripts/audit-permissions.js
const manifest = require('./src/manifest.json');
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const requestedPermissions = [
...(manifest.permissions || []),
...(manifest.optional_permissions || []),
...(manifest.host_permissions || []),
];
const apiToPermission = {
'chrome.tabs': 'tabs',
'chrome.history': 'history',
'chrome.bookmarks': 'bookmarks',
'chrome.cookies': 'cookies',
'chrome.downloads': 'downloads',
'chrome.geolocation': 'geolocation',
'chrome.identity': 'identity',
'chrome.management': 'management',
'chrome.notifications': 'notifications',
'chrome.webRequest': 'webRequest',
};
// Scan source files for API usage
function findApiUsage() {
const sourceDir = './src';
const jsFiles = execSync(`find ${sourceDir} -name "*.js"`)
.toString().trim().split('\n');
const usedApis = new Set();
for (const file of jsFiles) {
const content = fs.readFileSync(file, 'utf8');
for (const [api, permission] of Object.entries(apiToPermission)) {
if (content.includes(api)) {
usedApis.add(permission);
}
}
}
return usedApis;
}
const usedPermissions = findApiUsage();
const unusedPermissions = requestedPermissions.filter(
p => !usedPermissions.has(p) && !p.includes('://')
);
if (unusedPermissions.length > 0) {
console.warn('⚠️ Potentially unused permissions:', unusedPermissions.join(', '));
console.warn('Review and remove if not needed to reduce rejection risk.');
}Manual verification: For each permission in your manifest, document exactly which features require it. If you can't document a use, remove the permission.
2. Remote Code Execution Check
Chrome Web Store rejects extensions that execute remotely loaded code. Scan for violation patterns:
// scripts/check-remote-code.js
const { execSync } = require('child_process');
const violations = [];
// Check for eval() usage
const evalUsage = execSync("grep -r 'eval(' src/ --include='*.js' -l 2>/dev/null || true")
.toString().trim();
if (evalUsage) violations.push(`eval() found in: ${evalUsage}`);
// Check for new Function()
const funcUsage = execSync("grep -r 'new Function(' src/ --include='*.js' -l 2>/dev/null || true")
.toString().trim();
if (funcUsage) violations.push(`new Function() found in: ${funcUsage}`);
// Check for innerHTML with external data
const innerHTMLUsage = execSync("grep -r 'innerHTML' src/ --include='*.js' -l 2>/dev/null || true")
.toString().trim();
if (innerHTMLUsage) {
console.warn(`⚠️ innerHTML found in: ${innerHTMLUsage} — review for XSS risk`);
}
// Check for external script loading
const externalScripts = execSync("grep -r 'src.*http' src/ --include='*.html' -l 2>/dev/null || true")
.toString().trim();
if (externalScripts) violations.push(`External script loading in: ${externalScripts}`);
if (violations.length > 0) {
console.error('🚫 Remote code execution violations found:');
violations.forEach(v => console.error(` ${v}`));
process.exit(1);
}3. Content Security Policy Validation
Manifest V3 extensions have CSP enforced automatically, but validate your manifest's CSP if you have custom settings:
// tests/manifest.test.js
const manifest = require('../src/manifest.json');
describe('Content Security Policy', () => {
it('does not allow unsafe-eval in extension CSP', () => {
const csp = manifest.content_security_policy?.extension_pages;
if (csp) {
expect(csp).not.toContain('unsafe-eval');
expect(csp).not.toContain('unsafe-inline');
}
});
it('does not load scripts from external origins', () => {
const csp = manifest.content_security_policy?.extension_pages;
if (csp) {
// Should only reference 'self'
expect(csp).not.toMatch(/script-src.*https?:\/\//);
}
});
});4. Data Collection Disclosure Check
If your extension collects any user data, verify your manifest and listing disclose it:
describe('privacy compliance', () => {
it('privacy policy URL is specified if data is collected', () => {
// If extension collects user data, this must be in manifest or Store listing
const collectsData = manifest.permissions?.some(p =>
['cookies', 'history', 'identity', 'tabs'].includes(p)
);
if (collectsData) {
// At minimum, document this requirement — actual privacy policy is in Store listing
const hasPrivacyNote = require('../PRIVACY.md');
expect(hasPrivacyNote).toBeDefined();
}
});
it('does not send user data without disclosure', () => {
// Scan for fetch/XHR calls to external domains
const externalCalls = execSync(
"grep -r 'fetch\\|XMLHttpRequest\\|chrome.runtime.sendMessage' src/ --include='*.js' -n 2>/dev/null || true"
).toString();
// Manual review required — log for inspection
console.log('External communications to review:', externalCalls);
});
});5. Functional Testing Before Submission
Load the packed extension (not unpacked) for final testing:
# Pack the extension
# Chrome: Extensions > Pack extension (or via CLI)
npx web-ext build --source-dir ./src --artifacts-dir ./dist
# Load the .crx or .zip in Chrome (as unpacked doesn't test packaging)Test with the packed version:
// tests/e2e/pre-submission.spec.js
test('extension loads without errors from packed format', async () => {
const errors = [];
const browser = await chromium.launch({
args: [
`--load-extension=${PACKED_EXTENSION_PATH}`,
'--disable-extensions-except=' + PACKED_EXTENSION_PATH,
],
});
const context = browser.contexts()[0];
context.on('weberror', err => errors.push(err));
// Test all extension pages
const extensionId = await getExtensionId(browser);
const pages = ['popup.html', 'options.html'];
for (const page of pages) {
const p = await context.newPage();
await p.goto(`chrome-extension://${extensionId}/${page}`);
await p.waitForLoadState('networkidle');
await p.close();
}
expect(errors).toHaveLength(0);
await browser.close();
});6. Performance Testing
Google's review checks for extensions that impact browser performance:
test('extension popup loads within 500ms', async () => {
const browser = await chromium.launchPersistentContext('', {
args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],
});
const extensionId = await getExtensionId(browser);
const page = await browser.newPage();
const startTime = Date.now();
await page.goto(`chrome-extension://${extensionId}/popup.html`);
await page.waitForLoadState('networkidle');
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(500);
await browser.close();
});
test('content script does not significantly increase page load time', async () => {
// Baseline: page load without extension
const baselineTime = await measurePageLoad('https://example.com', { noExtension: true });
// With extension
const extensionTime = await measurePageLoad('https://example.com', { withExtension: true });
const overhead = extensionTime - baselineTime;
expect(overhead).toBeLessThan(200); // Extension adds less than 200ms
});7. Store Listing Validation
Before submission, verify your Store listing assets:
- Extension name ≤ 75 characters
- Short description ≤ 132 characters (Chrome requirement)
- Detailed description: accurate, no misleading claims
- Screenshots: 1280×800 or 640×400 pixels, show actual extension UI
- Promotional images match extension category (if provided)
- Privacy policy URL accessible and describes actual data handling
- Extension version follows semantic versioning
Automated Pre-Submission Pipeline
# .github/workflows/pre-submission.yml
name: Pre-Submission Checks
on:
push:
branches: [main]
workflow_dispatch:
jobs:
pre-submission:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- name: Lint manifest
run: npx web-ext lint --source-dir ./src
- name: Audit permissions
run: node scripts/audit-permissions.js
- name: Check remote code execution
run: node scripts/check-remote-code.js
- name: Build extension
run: npm run build
- name: Run unit tests
run: npm test
- name: Run E2E tests
run: xvfb-run npx playwright test --project=chrome
- name: Run performance tests
run: xvfb-run npx playwright test --project=performance
- name: Generate submission report
run: node scripts/generate-submission-report.js
- uses: actions/upload-artifact@v4
with:
name: submission-package
path: |
dist/
submission-report.mdWhat Google Actually Reviews
The review process is primarily automated with human review for flagged extensions. The automated checks focus on:
- Manifest validation (permissions, CSP)
- Static code analysis for policy violations
- Network request analysis (what external services does it call?)
- Store listing accuracy
Human reviewers look at:
- Extensions with broad permissions
- Extensions requesting sensitive permissions (downloads, nativeMessaging)
- Extensions with significant user data access
- Extensions in sensitive categories
Summary
Pre-submission testing should verify:
- Permissions: Only request permissions you actually use
- Remote code: No eval(), no external script loading
- CSP: Extension CSP doesn't allow unsafe patterns
- Privacy: Data collection is disclosed if present
- Functionality: Extension works from packed format, not just unpacked
- Performance: Popup loads fast, content script overhead is minimal
- Store listing: Accurate description, correct screenshot dimensions
The most impactful checks are permissions audit and remote code scanning — these catch the two most common rejection reasons. Run these automatically in CI so you catch violations before they reach submission.