diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index c83e19a..e85b6ff 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -46,7 +46,6 @@ jobs:
cp node_modules/@highlightjs/cdn-assets/es/languages/xml.min.js lib/xml.min.js
cp node_modules/@highlightjs/cdn-assets/es/languages/http.min.js lib/http.min.js
cp node_modules/@highlightjs/cdn-assets/es/languages/properties.min.js lib/properties.min.js
- cp node_modules/pako/dist/pako_inflate.min.js lib/pako_inflate.min.js
- name: Clean release
run: |
diff --git a/.gitignore b/.gitignore
index bc01dcd..b17e297 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@ lib/*.js
node_modules/
vendor/
composer.lock
+test-results/
+playwright-report/
diff --git a/attribution.md b/attribution.md
index 34b736b..0e18302 100644
--- a/attribution.md
+++ b/attribution.md
@@ -53,40 +53,3 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
-
----
-
-pako
-----
-
-License type: MIT License
-
-Project URL: https://github.com/nodeca/pako
-
-Original license text:
-
-```
-(The MIT License)
-
-Copyright (C) 2014-2017 by Vitaly Puzrin and Andrei Tuputcyn
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-```
-
----
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 3ea1445..a87469f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,10 +8,10 @@
"name": "saml-tracer",
"version": "1.9.2",
"dependencies": {
- "@highlightjs/cdn-assets": "11.11.1",
- "pako": "~2.2"
+ "@highlightjs/cdn-assets": "11.11.1"
},
"devDependencies": {
+ "@playwright/test": "^1.59.1",
"jest": "^29.0.0"
}
},
@@ -889,6 +889,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
+ "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@sinclair/typebox": {
"version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
@@ -3010,22 +3026,6 @@
"node": ">=6"
}
},
- "node_modules/pako": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
- "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/puzrin"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/nodeca"
- }
- ],
- "license": "(MIT AND Zlib)"
- },
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@@ -3125,6 +3125,53 @@
"node": ">=8"
}
},
+ "node_modules/playwright": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
+ "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
+ "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
diff --git a/package.json b/package.json
index 59d8d7c..b7e21b5 100644
--- a/package.json
+++ b/package.json
@@ -3,16 +3,18 @@
"version": "1.9.2",
"description": "A debugger for viewing SAML messages",
"scripts": {
- "test": "jest"
+ "test": "jest",
+ "test:e2e": "playwright test"
},
"dependencies": {
- "@highlightjs/cdn-assets": "11.11.1",
- "pako": "~2.2"
+ "@highlightjs/cdn-assets": "11.11.1"
},
"devDependencies": {
+ "@playwright/test": "^1.59.1",
"jest": "^29.0.0"
},
"jest": {
- "testEnvironment": "node"
+ "testEnvironment": "node",
+ "testMatch": ["**/*.test.js"]
}
}
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..bb7b0de
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,11 @@
+const { defineConfig } = require('@playwright/test');
+
+module.exports = defineConfig({
+ testDir: './test',
+ testMatch: '**/*.spec.js', // keep separate from Jest's *.test.js files
+ timeout: 60000,
+ reporter: 'list',
+ use: {
+ headless: false,
+ },
+});
diff --git a/src/SAMLTrace.js b/src/SAMLTrace.js
index 35b85ad..8d72809 100644
--- a/src/SAMLTrace.js
+++ b/src/SAMLTrace.js
@@ -5,7 +5,7 @@ if ("undefined" == typeof(SAMLTrace)) {
var SAMLTrace = {};
};
-SAMLTrace.b64inflate = function (data) {
+SAMLTrace.b64inflate = async function (data) {
// Remove any whitespace in the base64-encoded data -- Shibboleth may insert line feeds in the data.
data = data.replace(/\s/g, '');
@@ -21,7 +21,8 @@ SAMLTrace.b64inflate = function (data) {
const decoded = atob(data);
const bytes = Uint8Array.from(decoded, c => c.charCodeAt(0));
- const inflated = pako.inflateRaw(bytes);
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
+ const inflated = new Uint8Array(await new Response(stream).arrayBuffer());
return String.fromCharCode.apply(String, inflated);
};
@@ -145,7 +146,9 @@ SAMLTrace.Request = function(request, getResponse) {
this.loadPOST(request);
this.parsePOST();
this.parseProtocol();
- this.parseSAML();
+ // parseSAML() inflates the HTTP-Redirect binding via DecompressionStream and is therefore
+ // asynchronous. Callers must await it before rendering: RequestItem inspects `saml` and
+ // `samlart` to decide which tabs exist.
};
SAMLTrace.Request.prototype = {
'loadRequestHeaders' : function(request) {
@@ -269,7 +272,7 @@ SAMLTrace.Request.prototype = {
this.protocol = "WS-Fed";
}
},
- 'parseSAML' : function() {
+ 'parseSAML' : async function() {
if ((this.saml && this.saml !== "") || (this.samlart && this.samlart !== "")) {
// do nothing if the token of an imported request is already present
return;
@@ -301,12 +304,17 @@ SAMLTrace.Request.prototype = {
return parameter ? parameter[1] : null;
};
- return queries.some(query => {
+ // Sequential rather than Array.some(), because the inflating actions are asynchronous.
+ // Semantics are unchanged: every query up to and including the first match is applied.
+ for (const query of queries) {
let parameter = findParameter(query.name, query.collection);
- let value = query.action(parameter);
+ let value = await query.action(parameter);
query.to(value);
- return value !== null;
- });
+ if (value !== null) {
+ return true;
+ }
+ }
+ return false;
}
};
@@ -645,8 +653,10 @@ SAMLTrace.TraceWindow.prototype = {
return false;
},
- 'addRequestItem' : function(request, getResponse) {
+ 'addRequestItem' : async function(request, getResponse) {
var samlTracerRequest = new SAMLTrace.Request(request, getResponse);
+ // Parse before publishing the request, so nothing can observe a half-initialised entry.
+ await samlTracerRequest.parseSAML();
this.requests.push(samlTracerRequest);
request.parsed = samlTracerRequest;
@@ -760,7 +770,7 @@ SAMLTrace.TraceWindow.prototype = {
'attachHeadersToRequest' : function(request) { // onBeforeSendHeaders
let uniqueRequestId = new SAMLTrace.UniqueRequestId(request.requestId, request.method, request.url);
- uniqueRequestId.create(id => {
+ uniqueRequestId.create(async id => {
let tracer = SAMLTrace.TraceWindow.instance();
// Maybe revise the HTTP method on redirected requests
@@ -775,7 +785,7 @@ SAMLTrace.TraceWindow.prototype = {
}
entry.headers = request.requestHeaders;
- tracer.addRequestItem(entry, () => entry.res);
+ await tracer.addRequestItem(entry, () => entry.res);
tracer.updateStatusBar();
});
},
diff --git a/src/SAMLTraceIO.js b/src/SAMLTraceIO.js
index 35a229d..bf2df67 100644
--- a/src/SAMLTraceIO.js
+++ b/src/SAMLTraceIO.js
@@ -47,10 +47,10 @@ SAMLTraceIO.prototype = {
* Imports requests and restores them in the TraceWindow.
**/
'importRequests': function(selectedFile, tracer, onSuccess, onError) {
- const parseRequests = rawResult => {
+ const parseRequests = async rawResult => {
try {
let exportedSession = JSON.parse(rawResult);
- let successfullyRestored = this.restoreFromImport(exportedSession.requests, tracer, onError);
+ let successfullyRestored = await this.restoreFromImport(exportedSession.requests, tracer, onError);
if (successfullyRestored) {
onSuccess();
}
@@ -73,7 +73,7 @@ SAMLTraceIO.prototype = {
reader.readAsText(selectedFile);
},
- 'restoreFromImport' : function(importedRequests, tracer, onError) {
+ 'restoreFromImport' : async function(importedRequests, tracer, onError) {
if (!importedRequests || importedRequests.length === 0) {
onError("There aren't any requests to import...");
return false;
@@ -124,11 +124,13 @@ SAMLTraceIO.prototype = {
};
let restoreableRequests = importedRequests.map(ir => createRestoreableModel(ir));
- restoreableRequests.forEach(rr => {
+ // Sequential rather than forEach(), because addRequestItem() is asynchronous and
+ // attachResponseToRequest() must not run until the request has been added.
+ for (const rr of restoreableRequests) {
tracer.saveNewRequest(rr);
- tracer.addRequestItem(rr, rr.getResponse);
+ await tracer.addRequestItem(rr, rr.getResponse);
tracer.attachResponseToRequest(rr.getResponse());
- });
+ }
return true;
}
diff --git a/src/TraceWindow.html b/src/TraceWindow.html
index 0c4fe79..75e2492 100644
--- a/src/TraceWindow.html
+++ b/src/TraceWindow.html
@@ -38,7 +38,6 @@
-
diff --git a/test/E2E_TESTS.md b/test/E2E_TESTS.md
new file mode 100644
index 0000000..77d84d6
--- /dev/null
+++ b/test/E2E_TESTS.md
@@ -0,0 +1,60 @@
+# End-to-end tests
+
+These Playwright tests cover the full export pipeline running inside a real
+Chromium browser with the extension loaded. They exist to validate fixes for two
+bugs that unit tests alone could not reproduce in a browser context.
+
+## Background
+
+Both bugs cause the export to silently fail. When `perform` throws an unhandled
+exception, `ui.exportResult` is never updated and either retains its previous
+value (returning a stale export) or stays `null` (producing a file containing
+only the word `null`).
+
+**[Issue #69](https://github.com/simplesamlphp/SAML-tracer/issues/69):** A request
+that was blocked, cancelled, or interrupted before a response arrived causes
+`getResponse()` to return `undefined`. Passing `undefined` to `JSON.stringify`
+returns the JS value `undefined` rather than a string, so `JSON.parse` throws
+`SyntaxError: "undefined" is not valid JSON`.
+
+**[Issue #106](https://github.com/simplesamlphp/SAML-tracer/issues/106):** In a
+clear-and-retrace scenario, a request can arrive whose `entry.parsed` was never
+set (due to a race between the `onBeforeSendHeaders` and `onHeadersReceived`
+webRequest events). The export filter maps `req.parsed`, producing `undefined`
+in the request list, which then crashes `createFromJSON`.
+
+## Test approach
+
+The extension opens its tracer window as a popup via `browser.windows.create()`.
+Rather than trying to click the toolbar icon (which Playwright can't easily
+reach), the tests navigate directly to `chrome-extension:///src/TraceWindow.html`,
+giving Playwright a regular page it can fully control.
+
+The #69 test injects a fake entry directly into `window.tracer.httpRequests` to
+simulate the exact condition (`getResponse: () => undefined`) rather than trying
+to trigger it through browser network behaviour, which is intercepted before the
+extension's `webRequest` listeners fire.
+
+## Running the tests
+
+```
+npm run test:e2e
+```
+
+Chrome extensions require a display. On a headless CI host, wrap the command
+with `xvfb-run`:
+
+```
+xvfb-run npm run test:e2e
+```
+
+## Results
+
+All four tests pass with both fixes applied. Each bug test was also verified to
+fail correctly against the unfixed code:
+
+- The #69 test fails when `enrichWithResponse` in `SAMLTraceIO.js` does not guard
+ against `undefined` — the export comes back `null`.
+- The #106 test fails when `.filter(Boolean)` is absent from `exportDialog.js` —
+ the timestamp comparison detects that a stale export was served instead of a
+ fresh one.
diff --git a/test/SAMLTrace.test.js b/test/SAMLTrace.test.js
new file mode 100644
index 0000000..10badc8
--- /dev/null
+++ b/test/SAMLTrace.test.js
@@ -0,0 +1,54 @@
+const fs = require('fs');
+const path = require('path');
+const { deflateRawSync } = require('zlib');
+
+// Load the extension source in this test's own global scope rather than a fresh
+// vm context. b64inflate() relies on web globals (atob, Blob, Response,
+// DecompressionStream) that Jest's node environment provides directly — but under
+// Jest on Node >= 26, a context created via vm.runInNewContext(code, global) no
+// longer sees them, so the vm-based loader used elsewhere fails here. dump() only
+// exists in the extension's browser context, so we inject a no-op.
+function loadSAMLTrace() {
+ const code = fs.readFileSync(path.resolve(__dirname, '..', 'src/SAMLTrace.js'), 'utf8');
+ const factory = new Function('dump', `${code}\nreturn SAMLTrace;`);
+ return factory(() => {});
+}
+
+const SAMLTrace = loadSAMLTrace();
+
+/** Encodes a string the way an IdP encodes an HTTP-Redirect binding payload. */
+function redirectBindingEncode(xml) {
+ return deflateRawSync(Buffer.from(xml, 'utf8')).toString('base64');
+}
+
+describe('SAMLTrace.b64inflate', () => {
+ const xml = '';
+
+ it('inflates a base64-encoded, raw-deflated SAML message', async () => {
+ const inflated = await SAMLTrace.b64inflate(redirectBindingEncode(xml));
+ expect(inflated).toBe(xml);
+ });
+
+ it('tolerates line breaks inserted into the base64 data', async () => {
+ // Shibboleth wraps the encoded data across multiple lines.
+ const wrapped = redirectBindingEncode(xml).replace(/(.{8})/g, '$1\n');
+ const inflated = await SAMLTrace.b64inflate(wrapped);
+ expect(inflated).toBe(xml);
+ });
+
+ it('preserves raw byte semantics for non-ASCII payloads', async () => {
+ // pako.inflateRaw() + String.fromCharCode produced a latin1 byte-string, not decoded
+ // UTF-8. Downstream parsing depends on that, so the replacement must match it.
+ const unicodeXml = 'https://sp.example.org/ünïcode';
+ const inflated = await SAMLTrace.b64inflate(redirectBindingEncode(unicodeXml));
+ expect(inflated).toBe(Buffer.from(unicodeXml, 'utf8').toString('latin1'));
+ });
+
+ it('returns null when the data is not a multiple of 4 bytes', async () => {
+ await expect(SAMLTrace.b64inflate('abcde')).resolves.toBeNull();
+ });
+
+ it('returns null when the data is too short', async () => {
+ await expect(SAMLTrace.b64inflate('ab')).resolves.toBeNull();
+ });
+});
diff --git a/test/extension.spec.js b/test/extension.spec.js
new file mode 100644
index 0000000..2bcd156
--- /dev/null
+++ b/test/extension.spec.js
@@ -0,0 +1,200 @@
+const { test, expect, chromium } = require('@playwright/test');
+const path = require('path');
+const fs = require('fs');
+const { deflateRawSync } = require('zlib');
+
+const EXTENSION_PATH = path.resolve(__dirname, '..');
+
+async function launchWithExtension() {
+ // headless: false is required — Chrome extensions do not fully work in headless mode.
+ // In CI, run the suite under xvfb-run to provide a virtual display.
+ return chromium.launchPersistentContext('', {
+ headless: false,
+ args: [
+ `--disable-extensions-except=${EXTENSION_PATH}`,
+ `--load-extension=${EXTENSION_PATH}`,
+ ],
+ });
+}
+
+async function getExtensionId(context) {
+ let sw = context.serviceWorkers()[0];
+ if (!sw) sw = await context.waitForEvent('serviceworker');
+ return new URL(sw.url()).hostname;
+}
+
+async function openTracerPage(context, extensionId) {
+ // Navigate directly to TraceWindow.html rather than clicking the toolbar icon.
+ // This avoids dealing with the popup window lifecycle and gives Playwright a
+ // regular page it can fully control.
+ const page = await context.newPage();
+ await page.goto(`chrome-extension://${extensionId}/src/TraceWindow.html`);
+ return page;
+}
+
+async function doExport(tracerPage) {
+ await tracerPage.click('#button-export-list');
+ await tracerPage.waitForSelector('#exportDialog', { state: 'visible' });
+
+ const frame = tracerPage.frameLocator('#exportDialogContent');
+ const downloadPromise = tracerPage.waitForEvent('download');
+ // force:true bypasses the bottomRow overlay that Playwright's hit-testing flags
+ await frame.locator('#button-export').click({ force: true });
+ const download = await downloadPromise;
+
+ const downloadPath = await download.path();
+ const content = fs.readFileSync(downloadPath, 'utf8');
+ return JSON.parse(content);
+}
+
+test.describe('SAML Tracer export', () => {
+ let context;
+ let tracerPage;
+ let extensionId;
+
+ test.beforeAll(async () => {
+ context = await launchWithExtension();
+ extensionId = await getExtensionId(context);
+ tracerPage = await openTracerPage(context, extensionId);
+ });
+
+ test.afterAll(async () => {
+ await context.close();
+ });
+
+ test.beforeEach(async () => {
+ await tracerPage.click('#button-clear');
+ });
+
+ test('happy path — normal requests export successfully', async () => {
+ const testPage = await context.newPage();
+ await testPage.goto('https://example.com');
+ await tracerPage.waitForSelector('#request-list .list-row', { timeout: 10000 });
+
+ const result = await doExport(tracerPage);
+ expect(result).not.toBeNull();
+ expect(result.requests.length).toBeGreaterThan(0);
+
+ await testPage.close();
+ });
+
+ test('HTTP-Redirect binding — a deflated SAMLRequest is inflated and captured', async () => {
+ // Covers the inflate path itself: an HTTP-Redirect binding carries the AuthnRequest
+ // raw-deflated and base64-encoded in the query string, which parseSAML() must inflate.
+ const xml = ''
+ + ''
+ + 'https://sp.example.org/e2e';
+ const encoded = deflateRawSync(Buffer.from(xml, 'utf8')).toString('base64');
+
+ const testPage = await context.newPage();
+ await testPage.goto(`https://example.com/sso?SAMLRequest=${encodeURIComponent(encoded)}`);
+ await tracerPage.waitForSelector('#request-list .list-row', { timeout: 10000 });
+
+ const result = await doExport(tracerPage);
+ const entry = result.requests.find(r => r.url.includes('SAMLRequest='));
+ expect(entry).toBeDefined();
+ expect(entry.protocol).toBe('SAML-P');
+ expect(entry.saml).toContain('AuthnRequest');
+ expect(entry.saml).toContain('https://sp.example.org/e2e');
+
+ await testPage.close();
+ });
+
+ test('issue #69 — request with no response does not break export', async () => {
+ // Navigate first so we have at least one real captured request
+ const testPage = await context.newPage();
+ await testPage.goto('https://example.com');
+ await tracerPage.waitForSelector('#request-list .list-row', { timeout: 10000 });
+
+ // Inject a fake entry simulating a request where getResponse() returns
+ // undefined — the exact condition that caused the null export in #69
+ await tracerPage.evaluate(() => {
+ window.tracer.httpRequests.push({
+ id: 'fake-no-response',
+ isVisible: () => true,
+ parsed: {
+ method: 'GET',
+ url: 'https://example.com/blocked-resource',
+ requestId: 'fake-no-response',
+ post: [],
+ requestHeaders: [],
+ saml: null,
+ getResponse: () => undefined,
+ }
+ });
+ });
+
+ const result = await doExport(tracerPage);
+ expect(result).not.toBeNull();
+ expect(result.timestamp).toBeDefined();
+ // The fake entry should be present but without response fields
+ const fakeEntry = result.requests.find(r => r.url === 'https://example.com/blocked-resource');
+ expect(fakeEntry).toBeDefined();
+ expect(fakeEntry.responseStatus).toBeUndefined();
+
+ await testPage.close();
+ });
+
+ test('issue #106 — entry with no parsed value does not break export', async () => {
+ // Navigate first so we have at least one real captured request
+ const testPage = await context.newPage();
+ await testPage.goto('https://example.com');
+ await tracerPage.waitForSelector('#request-list .list-row', { timeout: 10000 });
+
+ // Inject a fake entry simulating the #106 condition: isVisible is set
+ // (meaning attachResponseToRequest fired) but parsed is undefined (meaning
+ // addRequestItem was never called due to a race around the pause state).
+ // Without the .filter(Boolean) fix this produces undefined in the request
+ // list passed to perform(), crashing createFromJSON and leaving the export stale.
+ await tracerPage.evaluate(() => {
+ window.tracer.httpRequests.push({
+ id: 'fake-no-parsed',
+ isVisible: () => true,
+ // parsed intentionally absent
+ });
+ });
+
+ // Record the time just before exporting so we can detect a stale result.
+ // Without the fix, perform() throws and ui.exportResult is never updated,
+ // so the download silently serves the previous test's cached export.
+ const beforeExport = new Date().toISOString();
+ const result = await doExport(tracerPage);
+ expect(result).not.toBeNull();
+ // A stale export has an older timestamp — this assertion fails without the fix
+ expect(result.timestamp >= beforeExport).toBe(true);
+ // The incomplete entry should be silently dropped; real requests still present
+ expect(result.requests.length).toBeGreaterThan(0);
+
+ await testPage.close();
+ });
+
+ test('issue #106 — clear and retrace produces a fresh export', async () => {
+ // The bug: after clearing and capturing a new trace, the export would sometimes
+ // return the previous cached result rather than the new one. This happens when
+ // an entry in httpRequests has isVisible set but no parsed value, causing
+ // createFromJSON to throw and leaving ui.exportResult stale.
+ // This test verifies that two consecutive export-clear-export cycles produce
+ // distinct, non-empty results.
+
+ // First trace
+ const page1 = await context.newPage();
+ await page1.goto('https://example.com');
+ await tracerPage.waitForSelector('#request-list .list-row', { timeout: 10000 });
+ const result1 = await doExport(tracerPage);
+ expect(result1.timestamp).toBeDefined();
+
+ // Clear and second trace
+ await tracerPage.click('#button-clear');
+ const page2 = await context.newPage();
+ await page2.goto('https://example.org');
+ await tracerPage.waitForSelector('#request-list .list-row', { timeout: 10000 });
+ const result2 = await doExport(tracerPage);
+
+ expect(result2.timestamp).not.toBe(result1.timestamp);
+ expect(result2.requests.length).toBeGreaterThan(0);
+
+ await page1.close();
+ await page2.close();
+ });
+});