Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,23 @@ jobs:
run: npm install
- name: Lint
run: npm run lint
test:
runs-on: ubuntu-latest
# official Playwright image ships chromium, firefox, and webkit plus their
# OS dependencies pre-installed, so no browser download/apt step is needed;
# keep the tag in sync with the `@playwright/test` version in package.json
container:
image: mcr.microsoft.com/playwright:v1.61.0-noble
# the image runs as root, but the Actions container mounts $HOME at
# /github/home (owned by pwuser); Firefox refuses to launch as root unless
# $HOME is owned by the current user, so point it at root's home
env:
HOME: /root
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
# the Playwright image already ships Node.js 22, so no setup-node step
- name: Install
run: npm install
- name: Test
run: npm test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ dist
node_modules
npm-debug.log
package-lock.json
playwright-report
reports
test-results
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# credential-handler-polyfill ChangeLog

## 4.0.4 - 2026-06-dd

### Added
- Cross-browser smoke test suite (Playwright) covering Chromium, Firefox, and
WebKit. Verifies `loadOnce()` resolves and patches `navigator.credentials`,
and includes a regression guard for the non-configurable
`navigator.credentials` case fixed in 4.0.2 (see #51 / #52). Runs as a `test`
CI job alongside lint.

## 4.0.3 - 2026-06-12

### Changed
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,21 @@ cd credential-handler-polyfill
npm install
```

### Testing

The polyfill has a cross-browser smoke test suite (Playwright) that loads the
built bundle in Chromium, Firefox, and WebKit and verifies that `loadOnce()`
resolves and patches `navigator.credentials`. It includes a regression guard
for the case where `navigator.credentials` is non-configurable (as on
Safari/iOS).

Install the browser binaries once, then run the tests:

```
npx playwright install --with-deps chromium firefox webkit
npm test
```

## Features

The CHAPI polyfill provides a number of features that enable the issuance,
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import globals from 'globals';
export default [
...config,
{
files: ['webpack.config.js'],
files: ['webpack.config.js', 'playwright.config.js', 'test/**/*.js'],
languageOptions: {
globals: {
...globals.node
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"scripts": {
"prepublish": "npm run build",
"build": "webpack",
"lint": "eslint --no-warn-ignored ."
"lint": "eslint --no-warn-ignored .",
"test": "playwright test"
},
"repository": {
"type": "git",
Expand All @@ -34,7 +35,9 @@
},
"devDependencies": {
"@digitalbazaar/eslint-config": "^8.0.1",
"@playwright/test": "^1.61.0",
"eslint": "^9.39.4",
"http-server": "^14.1.1",
"webpack": "^5.107.2",
"webpack-cli": "^7.0.3"
},
Expand Down
33 changes: 33 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*!
* Copyright (c) 2026 Digital Bazaar, Inc.
*/
import {defineConfig, devices} from '@playwright/test';

// Serves the repo root over http://localhost (a secure context, so the
// polyfill's `_assertSecureContext()` passes) and runs the smoke test across
// chromium, firefox, and webkit. The webkit project reproduces #51.
const PORT = 9876;

export default defineConfig({
testDir: './test',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: 'list',
use: {
baseURL: `http://localhost:${PORT}`
},
webServer: {
// build the bundle, then serve the repo root so /dist and /test
// are reachable
command: `npm run build && npx http-server -p ${PORT} -c-1 --silent .`,
url: `http://localhost:${PORT}/test/fixtures/index.html`,
reuseExistingServer: !process.env.CI,
timeout: 120000
},
projects: [
{name: 'chromium', use: {...devices['Desktop Chrome']}},
{name: 'firefox', use: {...devices['Desktop Firefox']}},
{name: 'webkit', use: {...devices['Desktop Safari']}}
]
});
19 changes: 19 additions & 0 deletions test/fixtures/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<!--
Copyright (c) 2026 Digital Bazaar, Inc.

Smoke-test fixture: loads the built UMD bundle and exposes the polyfill API
on `window.credentialHandlerPolyfill` so Playwright can drive `loadOnce()`
across browsers. Served over http://localhost (a secure context), so
`_assertSecureContext()` passes without HTTPS.
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<title>credential-handler-polyfill smoke test</title>
<script src="/dist/credential-handler-polyfill.min.js"></script>
</head>
<body>
<p>credential-handler-polyfill smoke test fixture</p>
</body>
</html>
121 changes: 121 additions & 0 deletions test/smoke.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*!
* Copyright (c) 2026 Digital Bazaar, Inc.
*/
import {expect, test} from '@playwright/test';

// Smoke test that reproduces #51: on WebKit, `navigator.credentials` is a
// non-configurable property, so the `Object.defineProperty()` call in `load()`
// throws and `loadOnce()` rejects. The fix in #52 wraps that in a try/catch and
// falls back to plain assignment. These assertions are red on WebKit before the
// fix and green after, and run across all configured browser projects.

test('loadOnce() resolves and patches navigator.credentials', async ({
page
}) => {
await page.goto('/test/fixtures/index.html');

const result = await page.evaluate(async () => {
// do not let the remote mediator window load actually block resolution;
// `loadOnce()` wires up the polyfill synchronously and returns before the
// mediator iframe finishes, so the API surface is observable immediately
await window.credentialHandlerPolyfill.loadOnce();
return {
hasWebCredential: typeof window.WebCredential === 'function',
getIsFn: typeof navigator.credentials.get === 'function',
storeIsFn: typeof navigator.credentials.store === 'function'
};
});

// the key regression assertion: the above did not throw (a WebKit pre-#52
// `loadOnce()` rejects with a TypeError here)
expect(result.hasWebCredential).toBe(true);
expect(result.getIsFn).toBe(true);
expect(result.storeIsFn).toBe(true);
});

test('loadOnce() resolves when navigator.credentials is non-configurable',
async ({page}, testInfo) => {
// Scoped to chromium + firefox. When `navigator.credentials` is forced to
// a non-configurable descriptor, Linux WebKit (the Playwright CI image)
// leaves `navigator.credentials.get` undefined after `load()` patches it,
// so the post-conditions cannot be asserted there; macOS WebKit does not
// exhibit this. The forced-descriptor simulation is an engine-sensitive
// stand-in for real Safari either way, so we run this deterministic guard
// on the two engines where it is stable. The plain cross-browser smoke
// test above still exercises `load()` under WebKit.
test.skip(testInfo.project.name === 'webkit',
'forced non-configurable descriptor is not portable to Linux WebKit');

await page.goto('/test/fixtures/index.html');

// True regression guard for #51. Playwright's bundled engines (incl.
// WebKit 26.5) report `navigator.credentials` as `configurable: true`,
// so they do NOT reproduce real Safari/iOS on their own. We force the
// Safari shape here so the test is red on the pre-#52 (unguarded
// defineProperty) code in every engine and green with the try/catch
// fallback.
const result = await page.evaluate(async () => {
// Redefine `navigator.credentials` as a non-configurable, getter-only
// accessor returning the existing object. This is the shape #52
// describes WebKit using, and it is what makes the polyfill's
// `Object.defineProperty()` call throw (you cannot redefine a
// non-configurable property, and you cannot convert an accessor to a
// data property). A non-configurable but *writable data* property does
// NOT throw on redefine, so it would not reproduce the bug; freezing the
// object as a non-writable data property is rejected differently across
// engines. Getter-only is both faithful and portable: the object itself
// stays mutable, so the polyfill's earlier direct patching of
// `get`/`store` still succeeds.
const current = navigator.credentials;
Object.defineProperty(navigator, 'credentials', {
get() {
return current;
},
configurable: false
});
let threw = false;
try {
await window.credentialHandlerPolyfill.loadOnce();
} catch(e) {
threw = e.name + ': ' + e.message;
}
return {
threw,
getIsFn: typeof navigator.credentials.get === 'function',
storeIsFn: typeof navigator.credentials.store === 'function'
};
});

// pre-#52: `threw` is a TypeError string ("Cannot redefine property" /
// "Attempting to change access mechanism for an unconfigurable property")
expect(result.threw).toBe(false);
expect(result.getIsFn).toBe(true);
expect(result.storeIsFn).toBe(true);
});

test('survives navigator.credentials being reassigned after load', async ({
page
}) => {
await page.goto('/test/fixtures/index.html');

// simulates a password-manager extension (1Password, Dashlane, etc.)
// reassigning `navigator.credentials` after the polyfill loads. The polyfill
// patches `get`/`store` directly on the existing object before installing the
// overwrite proxy, so CHAPI must still be usable. Browser-agnostic stand-in
// for real extensions, which Playwright can only load under Chromium.
const stillPatched = await page.evaluate(async () => {
await window.credentialHandlerPolyfill.loadOnce();
try {
// mimic an extension clobbering the property
navigator.credentials.get = function() {
return Promise.resolve('extension-result');
};
} catch {
// some engines may reject reassignment; that is acceptable
}
return typeof navigator.credentials.get === 'function' &&
typeof navigator.credentials.store === 'function';
});

expect(stillPatched).toBe(true);
});