Web Inspector: Network Tab - Cookies Detail View
authorjoepeck@webkit.org <joepeck@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Mon, 9 Oct 2017 19:32:28 +0000 (19:32 +0000)
committerjoepeck@webkit.org <joepeck@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Mon, 9 Oct 2017 19:32:28 +0000 (19:32 +0000)
https://bugs.webkit.org/show_bug.cgi?id=177988
<rdar://problem/34071927>

Reviewed by Brian Burg.

Source/WebInspectorUI:

* Localizations/en.lproj/localizedStrings.js:
* UserInterface/Main.html:
* UserInterface/Test.html:
New strings and resources.

* UserInterface/Models/Cookie.js: Added.
(WI.Cookie):
(WI.Cookie.parseCookieRequestHeader):
(WI.Cookie.parseSetCookieResponseHeader):
Encapsulation for Cookie attributes.

* UserInterface/Models/Resource.js:
(WI.Resource.prototype.get requestCookies):
(WI.Resource.prototype.get responseCookies):
(WI.Resource.prototype.updateForRedirectResponse):
(WI.Resource.prototype.updateForResponse):
(WI.Resource.prototype.updateWithMetrics):
New computed accessors for requestCookies and responseCookies.

* UserInterface/Views/NetworkResourceDetailView.js:
(WI.NetworkResourceDetailView.prototype._showContentViewForNavigationItem):
Show the new Cookie View.

* UserInterface/Views/NetworkTableContentView.css:
(.content-view.network .network-table .icon):
(.network-table li:not(.filler) .cell.name):
(.network-table .cache-type):
(.network-table .error):
(body[dir=ltr] .network-table .cell.name > .status):
(body[dir=rtl] .network-table .cell.name > .status):
(.network-table .cell.name > .status .indeterminate-progress-spinner):
(.showing-detail .network-table .cell:not(.name)):
(.showing-detail .network-table .resizer:not(:first-of-type)):
(.network-table :not(.header) .cell:first-of-type):
Rework these styles to be specific to the .network-table.

* UserInterface/Views/Table.css:
(.table :not(.header) .cell:first-of-type): Deleted.
Move this to the network table styles, it shouldn't apply to all tables.

* UserInterface/Views/ResourceCookiesContentView.css:
(.resource-cookies > section > .details.has-table):
(.resource-cookies .table):
(.resource-cookies .table > .header):
Styles for Cookies view and table.

* UserInterface/Views/ResourceCookiesContentView.js: Added.
(WI.ResourceCookiesContentView):
(WI.ResourceCookiesContentView.prototype.tableNumberOfRows):
(WI.ResourceCookiesContentView.prototype.tableSortChanged):
(WI.ResourceCookiesContentView.prototype.tablePopulateCell):
(WI.ResourceCookiesContentView.prototype.initialLayout):
(WI.ResourceCookiesContentView.prototype._incompleteSectionWithMessage):
(WI.ResourceCookiesContentView.prototype._incompleteSectionWithLoadingIndicator):
(WI.ResourceCookiesContentView.prototype._dataSourceForTable):
(WI.ResourceCookiesContentView.prototype._generateSortComparator):
(WI.ResourceCookiesContentView.prototype._refreshRequestCookiesSection):
(WI.ResourceCookiesContentView.prototype._refreshResponseCookiesSection):
(WI.ResourceCookiesContentView.prototype._sizeForTable):
(WI.ResourceCookiesContentView.prototype._resourceRequestHeadersDidChange):
(WI.ResourceCookiesContentView.prototype._resourceResponseReceived):
Tables for Request and Response cookies. They are simliar with slightly different columns.
Handle simple display and sorting for the tables.

* UserInterface/Views/ResourceHeadersContentView.js:
(WI.ResourceHeadersContentView.prototype._refreshResponseHeadersSection):
Break out Set-Cookie headers as multiple headers. THey should never be combined.

* UserInterface/Views/Table.js:
(WI.Table.prototype.showColumn):
(WI.Table.prototype.hideColumn):
(WI.Table.prototype._handleHeaderContextMenu):
* UserInterface/Views/TableColumn.js:
(WI.TableColumn.prototype.get hideable):
(WI.TableColumn.prototype.set hidden):
(WI.TableColumn):
(WI.TableColumn.prototype.setHidden): Deleted.
Make it so some columns can not be hidden. For example the "value" column
in the Cookie tables.

LayoutTests:

* inspector/unit-tests/cookie-expected.txt: Added.
* inspector/unit-tests/cookie.html: Added.

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@223058 268f45cc-cd09-0410-ab3c-d52691b4dbfc

17 files changed:
LayoutTests/ChangeLog
LayoutTests/inspector/unit-tests/cookie-expected.txt [new file with mode: 0644]
LayoutTests/inspector/unit-tests/cookie.html [new file with mode: 0644]
Source/WebInspectorUI/ChangeLog
Source/WebInspectorUI/Localizations/en.lproj/localizedStrings.js
Source/WebInspectorUI/UserInterface/Main.html
Source/WebInspectorUI/UserInterface/Models/Cookie.js [new file with mode: 0644]
Source/WebInspectorUI/UserInterface/Models/Resource.js
Source/WebInspectorUI/UserInterface/Test.html
Source/WebInspectorUI/UserInterface/Views/NetworkResourceDetailView.js
Source/WebInspectorUI/UserInterface/Views/NetworkTableContentView.css
Source/WebInspectorUI/UserInterface/Views/ResourceCookiesContentView.css [new file with mode: 0644]
Source/WebInspectorUI/UserInterface/Views/ResourceCookiesContentView.js [new file with mode: 0644]
Source/WebInspectorUI/UserInterface/Views/ResourceHeadersContentView.js
Source/WebInspectorUI/UserInterface/Views/Table.css
Source/WebInspectorUI/UserInterface/Views/Table.js
Source/WebInspectorUI/UserInterface/Views/TableColumn.js

index 7096b2f..0827297 100644 (file)
@@ -1,3 +1,14 @@
+2017-10-09  Joseph Pecoraro  <pecoraro@apple.com>
+
+        Web Inspector: Network Tab - Cookies Detail View
+        https://bugs.webkit.org/show_bug.cgi?id=177988
+        <rdar://problem/34071927>
+
+        Reviewed by Brian Burg.
+
+        * inspector/unit-tests/cookie-expected.txt: Added.
+        * inspector/unit-tests/cookie.html: Added.
+
 2017-10-09  Matt Lewis  <jlewis3@apple.com>
 
         Marked inspector/dom/csp-big5-hash.html as flaky.
diff --git a/LayoutTests/inspector/unit-tests/cookie-expected.txt b/LayoutTests/inspector/unit-tests/cookie-expected.txt
new file mode 100644 (file)
index 0000000..e4dd1ca
--- /dev/null
@@ -0,0 +1,203 @@
+Testing WI.Cookie.
+
+
+== Running test suite: Cookie
+-- Running test case: WI.Cookie.parseCookieRequestHeader
+HEADER: Cookie: 
+PASS: Should have 0 cookies.
+
+HEADER: Cookie: foo=bar
+PASS: Should have 1 cookies.
+PASS: Value should be a WI.Cookie.
+PASS: cookie.type should be WI.Cookie.Type.Request.
+PASS: cookie.name should be 'foo'.
+PASS: cookie.value should be 'bar'.
+
+HEADER: Cookie: foo=bar; alpha=beta
+PASS: Should have 2 cookies.
+PASS: Value should be a WI.Cookie.
+PASS: cookie.type should be WI.Cookie.Type.Request.
+PASS: cookie.name should be 'foo'.
+PASS: cookie.value should be 'bar'.
+PASS: Value should be a WI.Cookie.
+PASS: cookie.type should be WI.Cookie.Type.Request.
+PASS: cookie.name should be 'alpha'.
+PASS: cookie.value should be 'beta'.
+
+HEADER: Cookie: foo=a,b,c, d, e, f,g; alpha=123423 qwerty; beta=gamma
+PASS: Should have 3 cookies.
+PASS: Value should be a WI.Cookie.
+PASS: cookie.type should be WI.Cookie.Type.Request.
+PASS: cookie.name should be 'foo'.
+PASS: cookie.value should be 'a,b,c, d, e, f,g'.
+PASS: Value should be a WI.Cookie.
+PASS: cookie.type should be WI.Cookie.Type.Request.
+PASS: cookie.name should be 'alpha'.
+PASS: cookie.value should be '123423 qwerty'.
+PASS: Value should be a WI.Cookie.
+PASS: cookie.type should be WI.Cookie.Type.Request.
+PASS: cookie.name should be 'beta'.
+PASS: cookie.value should be 'gamma'.
+
+
+-- Running test case: WI.Cookie.parseSetCookieResponseHeader
+PASS: Empty header should produce null.
+HEADER: Set-Cookie: name=value
+PASS: Value should be a WI.Cookie.
+PASS: cookie.rawHeader should be the original header text.
+PASS: cookie.type should be WI.Cookie.Type.Response.
+PASS: cookie.name should be 'name'.
+PASS: cookie.value should be 'value'.
+PASS: cookie.expires should be 'null'.
+PASS: cookie.maxAge should be 'null'.
+PASS: cookie.path should be 'null'.
+PASS: cookie.domain should be 'null'.
+PASS: cookie.secure should be 'false'.
+PASS: cookie.httpOnly should be 'false'.
+
+HEADER: Set-Cookie: name=value; path=/foo
+PASS: Value should be a WI.Cookie.
+PASS: cookie.rawHeader should be the original header text.
+PASS: cookie.type should be WI.Cookie.Type.Response.
+PASS: cookie.name should be 'name'.
+PASS: cookie.value should be 'value'.
+PASS: cookie.expires should be 'null'.
+PASS: cookie.maxAge should be 'null'.
+PASS: cookie.path should be '/foo'.
+PASS: cookie.domain should be 'null'.
+PASS: cookie.secure should be 'false'.
+PASS: cookie.httpOnly should be 'false'.
+
+HEADER: Set-Cookie: name=value; domain=example.com
+PASS: Value should be a WI.Cookie.
+PASS: cookie.rawHeader should be the original header text.
+PASS: cookie.type should be WI.Cookie.Type.Response.
+PASS: cookie.name should be 'name'.
+PASS: cookie.value should be 'value'.
+PASS: cookie.expires should be 'null'.
+PASS: cookie.maxAge should be 'null'.
+PASS: cookie.path should be 'null'.
+PASS: cookie.domain should be 'example.com'.
+PASS: cookie.secure should be 'false'.
+PASS: cookie.httpOnly should be 'false'.
+
+HEADER: Set-Cookie: name=value; secure
+PASS: Value should be a WI.Cookie.
+PASS: cookie.rawHeader should be the original header text.
+PASS: cookie.type should be WI.Cookie.Type.Response.
+PASS: cookie.name should be 'name'.
+PASS: cookie.value should be 'value'.
+PASS: cookie.expires should be 'null'.
+PASS: cookie.maxAge should be 'null'.
+PASS: cookie.path should be 'null'.
+PASS: cookie.domain should be 'null'.
+PASS: cookie.secure should be 'true'.
+PASS: cookie.httpOnly should be 'false'.
+
+HEADER: Set-Cookie: name=value; Secure
+PASS: Value should be a WI.Cookie.
+PASS: cookie.rawHeader should be the original header text.
+PASS: cookie.type should be WI.Cookie.Type.Response.
+PASS: cookie.name should be 'name'.
+PASS: cookie.value should be 'value'.
+PASS: cookie.expires should be 'null'.
+PASS: cookie.maxAge should be 'null'.
+PASS: cookie.path should be 'null'.
+PASS: cookie.domain should be 'null'.
+PASS: cookie.secure should be 'true'.
+PASS: cookie.httpOnly should be 'false'.
+
+HEADER: Set-Cookie: name=value; HttpOnly
+PASS: Value should be a WI.Cookie.
+PASS: cookie.rawHeader should be the original header text.
+PASS: cookie.type should be WI.Cookie.Type.Response.
+PASS: cookie.name should be 'name'.
+PASS: cookie.value should be 'value'.
+PASS: cookie.expires should be 'null'.
+PASS: cookie.maxAge should be 'null'.
+PASS: cookie.path should be 'null'.
+PASS: cookie.domain should be 'null'.
+PASS: cookie.secure should be 'false'.
+PASS: cookie.httpOnly should be 'true'.
+
+HEADER: Set-Cookie: name=value; expires=Fri 06-Oct-2017 03:20:27 GMT; Max-Age=3600
+PASS: Value should be a WI.Cookie.
+PASS: cookie.rawHeader should be the original header text.
+PASS: cookie.type should be WI.Cookie.Type.Response.
+PASS: cookie.name should be 'name'.
+PASS: cookie.value should be 'value'.
+PASS: cookie.expires should be 'Thu Oct 05 2017 20:20:27 GMT-0700 (PDT)'.
+PASS: cookie.maxAge should be '3600'.
+PASS: cookie.path should be 'null'.
+PASS: cookie.domain should be 'null'.
+PASS: cookie.secure should be 'false'.
+PASS: cookie.httpOnly should be 'false'.
+
+HEADER: Set-Cookie: name=value; expires=Fri 06-Oct-2017 03:43:47 GMT; Max-Age=5000; path=/foo; domain=example.com; secure; HttpOnly
+PASS: Value should be a WI.Cookie.
+PASS: cookie.rawHeader should be the original header text.
+PASS: cookie.type should be WI.Cookie.Type.Response.
+PASS: cookie.name should be 'name'.
+PASS: cookie.value should be 'value'.
+PASS: cookie.expires should be 'Thu Oct 05 2017 20:43:47 GMT-0700 (PDT)'.
+PASS: cookie.maxAge should be '5000'.
+PASS: cookie.path should be '/foo'.
+PASS: cookie.domain should be 'example.com'.
+PASS: cookie.secure should be 'true'.
+PASS: cookie.httpOnly should be 'true'.
+
+HEADER: Set-Cookie: name=value; Unknown; path=/one/two
+WARN: Unknown Cookie attribute: Unknown
+PASS: Value should be a WI.Cookie.
+PASS: cookie.rawHeader should be the original header text.
+PASS: cookie.type should be WI.Cookie.Type.Response.
+PASS: cookie.name should be 'name'.
+PASS: cookie.value should be 'value'.
+PASS: cookie.expires should be 'null'.
+PASS: cookie.maxAge should be 'null'.
+PASS: cookie.path should be '/one/two'.
+PASS: cookie.domain should be 'null'.
+PASS: cookie.secure should be 'false'.
+PASS: cookie.httpOnly should be 'false'.
+
+HEADER: Set-Cookie: name=value; Unknown=Ignored; path=/one/two
+WARN: Unknown Cookie attribute: Unknown=Ignored
+PASS: Value should be a WI.Cookie.
+PASS: cookie.rawHeader should be the original header text.
+PASS: cookie.type should be WI.Cookie.Type.Response.
+PASS: cookie.name should be 'name'.
+PASS: cookie.value should be 'value'.
+PASS: cookie.expires should be 'null'.
+PASS: cookie.maxAge should be 'null'.
+PASS: cookie.path should be '/one/two'.
+PASS: cookie.domain should be 'null'.
+PASS: cookie.secure should be 'false'.
+PASS: cookie.httpOnly should be 'false'.
+
+HEADER: Set-Cookie: name=somewhat longer value than normal with spaces, and commas; domain=other.example.com
+PASS: Value should be a WI.Cookie.
+PASS: cookie.rawHeader should be the original header text.
+PASS: cookie.type should be WI.Cookie.Type.Response.
+PASS: cookie.name should be 'name'.
+PASS: cookie.value should be 'somewhat longer value than normal with spaces, and commas'.
+PASS: cookie.expires should be 'null'.
+PASS: cookie.maxAge should be 'null'.
+PASS: cookie.path should be 'null'.
+PASS: cookie.domain should be 'other.example.com'.
+PASS: cookie.secure should be 'false'.
+PASS: cookie.httpOnly should be 'false'.
+
+HEADER: Set-Cookie: name==value=;Domain=.example.com;Expires=Wed, 04-Apr-2018 03:34:02 GMT
+PASS: Value should be a WI.Cookie.
+PASS: cookie.rawHeader should be the original header text.
+PASS: cookie.type should be WI.Cookie.Type.Response.
+PASS: cookie.name should be 'name'.
+PASS: cookie.value should be '=value='.
+PASS: cookie.expires should be 'Tue Apr 03 2018 20:34:02 GMT-0700 (PDT)'.
+PASS: cookie.maxAge should be 'null'.
+PASS: cookie.path should be 'null'.
+PASS: cookie.domain should be '.example.com'.
+PASS: cookie.secure should be 'false'.
+PASS: cookie.httpOnly should be 'false'.
+
+
diff --git a/LayoutTests/inspector/unit-tests/cookie.html b/LayoutTests/inspector/unit-tests/cookie.html
new file mode 100644 (file)
index 0000000..f01e5cc
--- /dev/null
@@ -0,0 +1,220 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="../../http/tests/inspector/resources/inspector-test.js"></script>
+<script>
+function test()
+{
+    let suite = InspectorTest.createSyncSuite("Cookie");
+
+    suite.addTestCase({
+        name: "WI.Cookie.parseCookieRequestHeader",
+        description: "Cookie request header.",
+        test() {
+            function test(header, expectedCookies) {
+                InspectorTest.log(`HEADER: Cookie: ${header}`);
+                let cookies = WI.Cookie.parseCookieRequestHeader(header);
+                InspectorTest.expectEqual(cookies.length, expectedCookies.length, `Should have ${expectedCookies.length} cookies.`);
+                for (let i = 0; i < cookies.length; ++i) {
+                    let cookie = cookies[i];
+                    let expected = expectedCookies[i];
+                    InspectorTest.expectThat(cookie instanceof WI.Cookie, `Value should be a WI.Cookie.`);
+                    InspectorTest.expectEqual(cookie.type, WI.Cookie.Type.Request, `cookie.type should be WI.Cookie.Type.Request.`);
+                    InspectorTest.expectEqual(cookie.name, expected.name, `cookie.name should be '${expected.name}'.`);
+                    InspectorTest.expectEqual(cookie.value, expected.value, `cookie.value should be '${expected.value}'.`);
+                }
+                InspectorTest.log("");
+            }
+
+            test("", []);
+
+            test(`foo=bar`, [
+                {name: "foo", value: "bar"},
+            ]);
+
+            test(`foo=bar; alpha=beta`, [
+                {name: "foo", value: "bar"},
+                {name: "alpha", value: "beta"},
+            ]);
+
+            test(`foo=a,b,c, d, e, f,g; alpha=123423 qwerty; beta=gamma`, [
+                {name: "foo", value: "a,b,c, d, e, f,g"},
+                {name: "alpha", value: "123423 qwerty"},
+                {name: "beta", value: "gamma"},
+            ]);
+
+            return true;
+        }
+    });
+
+    suite.addTestCase({
+        name: "WI.Cookie.parseSetCookieResponseHeader",
+        description: "Set-Cookie response headers.",
+        test() {
+            function test(header, expected) {
+                InspectorTest.log(`HEADER: Set-Cookie: ${header}`);
+                let cookie = WI.Cookie.parseSetCookieResponseHeader(header);
+                InspectorTest.expectThat(cookie instanceof WI.Cookie, `Value should be a WI.Cookie.`);
+                InspectorTest.expectEqual(cookie.rawHeader, header, `cookie.rawHeader should be the original header text.`);
+                InspectorTest.expectEqual(cookie.type, WI.Cookie.Type.Response, `cookie.type should be WI.Cookie.Type.Response.`);
+                InspectorTest.expectEqual(cookie.name, expected.name, `cookie.name should be '${expected.name}'.`);
+                InspectorTest.expectEqual(cookie.value, expected.value, `cookie.value should be '${expected.value}'.`);
+                if (cookie.expires && expected.expires)
+                    InspectorTest.expectEqual(cookie.expires.getTime(), expected.expires.getTime(), `cookie.expires should be '${expected.expires}'.`);
+                else
+                    InspectorTest.expectEqual(cookie.expires, expected.expires, `cookie.expires should be '${expected.expires}'.`);
+                InspectorTest.expectEqual(cookie.maxAge, expected.maxAge, `cookie.maxAge should be '${expected.maxAge}'.`);
+                InspectorTest.expectEqual(cookie.path, expected.path, `cookie.path should be '${expected.path}'.`);
+                InspectorTest.expectEqual(cookie.domain, expected.domain, `cookie.domain should be '${expected.domain}'.`);
+                InspectorTest.expectEqual(cookie.secure, expected.secure, `cookie.secure should be '${expected.secure}'.`);
+                InspectorTest.expectEqual(cookie.httpOnly, expected.httpOnly, `cookie.httpOnly should be '${expected.httpOnly}'.`);
+                InspectorTest.log("");
+            }
+
+            InspectorTest.expectNull(WI.Cookie.parseSetCookieResponseHeader(""), "Empty header should produce null.");
+
+            test(`name=value`, {
+                name: "name",
+                value: "value",
+                expires: null,
+                maxAge: null,
+                path: null,
+                domain: null,
+                secure: false,
+                httpOnly: false,
+            });
+
+            test(`name=value; path=/foo`, {
+                name: "name",
+                value: "value",
+                expires: null,
+                maxAge: null,
+                path: "/foo",
+                domain: null,
+                secure: false,
+                httpOnly: false,
+            });
+
+            test(`name=value; domain=example.com`, {
+                name: "name",
+                value: "value",
+                expires: null,
+                maxAge: null,
+                path: null,
+                domain: "example.com",
+                secure: false,
+                httpOnly: false,
+            });
+
+            test(`name=value; secure`, {
+                name: "name",
+                value: "value",
+                expires: null,
+                maxAge: null,
+                path: null,
+                domain: null,
+                secure: true,
+                httpOnly: false,
+            });
+
+            test(`name=value; Secure`, {
+                name: "name",
+                value: "value",
+                expires: null,
+                maxAge: null,
+                path: null,
+                domain: null,
+                secure: true,
+                httpOnly: false,
+            });
+
+            test(`name=value; HttpOnly`, {
+                name: "name",
+                value: "value",
+                expires: null,
+                maxAge: null,
+                path: null,
+                domain: null,
+                secure: false,
+                httpOnly: true,
+            });
+
+            test(`name=value; expires=Fri 06-Oct-2017 03:20:27 GMT; Max-Age=3600`, {
+                name: "name",
+                value: "value",
+                expires: new Date("Fri 06-Oct-2017 03:20:27 GMT"),
+                maxAge: 3600,
+                path: null,
+                domain: null,
+                secure: false,
+                httpOnly: false,
+            });
+
+            test(`name=value; expires=Fri 06-Oct-2017 03:43:47 GMT; Max-Age=5000; path=/foo; domain=example.com; secure; HttpOnly`, {
+                name: "name",
+                value: "value",
+                expires: new Date("Fri 06-Oct-2017 03:43:47 GMT"),
+                maxAge: 5000,
+                path: "/foo",
+                domain: "example.com",
+                secure: true,
+                httpOnly: true,
+            });
+
+            test(`name=value; Unknown; path=/one/two`, {
+                name: "name",
+                value: "value",
+                expires: null,
+                maxAge: null,
+                path: "/one/two",
+                domain: null,
+                secure: false,
+                httpOnly: false,
+            });
+
+            test(`name=value; Unknown=Ignored; path=/one/two`, {
+                name: "name",
+                value: "value",
+                expires: null,
+                maxAge: null,
+                path: "/one/two",
+                domain: null,
+                secure: false,
+                httpOnly: false,
+            });
+
+            test(`name=somewhat longer value than normal with spaces, and commas; domain=other.example.com`, {
+                name: "name",
+                value: "somewhat longer value than normal with spaces, and commas",
+                expires: null,
+                maxAge: null,
+                path: null,
+                domain: "other.example.com",
+                secure: false,
+                httpOnly: false,
+            });
+
+            // Some servers omit the required space after the semicolon.
+            test(`name==value=;Domain=.example.com;Expires=Wed, 04-Apr-2018 03:34:02 GMT`, {
+                name: "name",
+                value: "=value=",
+                expires: new Date("Wed, 04-Apr-2018 03:34:02 GMT"),
+                maxAge: null,
+                path: null,
+                domain: ".example.com",
+                secure: false,
+                httpOnly: false,
+            });
+
+            return true;
+        }
+    });
+
+    suite.runTestCasesAndFinish();
+}
+</script>
+</head>
+<body onload="runTest()">
+<p>Testing WI.Cookie.</p>
+</body>
+</html>
index 2dadf61..b991fa8 100644 (file)
@@ -1,5 +1,93 @@
 2017-10-09  Joseph Pecoraro  <pecoraro@apple.com>
 
+        Web Inspector: Network Tab - Cookies Detail View
+        https://bugs.webkit.org/show_bug.cgi?id=177988
+        <rdar://problem/34071927>
+
+        Reviewed by Brian Burg.
+
+        * Localizations/en.lproj/localizedStrings.js:
+        * UserInterface/Main.html:
+        * UserInterface/Test.html:
+        New strings and resources.
+
+        * UserInterface/Models/Cookie.js: Added.
+        (WI.Cookie):
+        (WI.Cookie.parseCookieRequestHeader):
+        (WI.Cookie.parseSetCookieResponseHeader):
+        Encapsulation for Cookie attributes.
+
+        * UserInterface/Models/Resource.js:
+        (WI.Resource.prototype.get requestCookies):
+        (WI.Resource.prototype.get responseCookies):
+        (WI.Resource.prototype.updateForRedirectResponse):
+        (WI.Resource.prototype.updateForResponse):
+        (WI.Resource.prototype.updateWithMetrics):
+        New computed accessors for requestCookies and responseCookies.
+
+        * UserInterface/Views/NetworkResourceDetailView.js:
+        (WI.NetworkResourceDetailView.prototype._showContentViewForNavigationItem):
+        Show the new Cookie View.
+
+        * UserInterface/Views/NetworkTableContentView.css:
+        (.content-view.network .network-table .icon):
+        (.network-table li:not(.filler) .cell.name):
+        (.network-table .cache-type):
+        (.network-table .error):
+        (body[dir=ltr] .network-table .cell.name > .status):
+        (body[dir=rtl] .network-table .cell.name > .status):
+        (.network-table .cell.name > .status .indeterminate-progress-spinner):
+        (.showing-detail .network-table .cell:not(.name)):
+        (.showing-detail .network-table .resizer:not(:first-of-type)):
+        (.network-table :not(.header) .cell:first-of-type):
+        Rework these styles to be specific to the .network-table.
+
+        * UserInterface/Views/Table.css:
+        (.table :not(.header) .cell:first-of-type): Deleted.
+        Move this to the network table styles, it shouldn't apply to all tables. 
+
+        * UserInterface/Views/ResourceCookiesContentView.css:
+        (.resource-cookies > section > .details.has-table):
+        (.resource-cookies .table):
+        (.resource-cookies .table > .header):
+        Styles for Cookies view and table.
+
+        * UserInterface/Views/ResourceCookiesContentView.js: Added.
+        (WI.ResourceCookiesContentView):
+        (WI.ResourceCookiesContentView.prototype.tableNumberOfRows):
+        (WI.ResourceCookiesContentView.prototype.tableSortChanged):
+        (WI.ResourceCookiesContentView.prototype.tablePopulateCell):
+        (WI.ResourceCookiesContentView.prototype.initialLayout):
+        (WI.ResourceCookiesContentView.prototype._incompleteSectionWithMessage):
+        (WI.ResourceCookiesContentView.prototype._incompleteSectionWithLoadingIndicator):
+        (WI.ResourceCookiesContentView.prototype._dataSourceForTable):
+        (WI.ResourceCookiesContentView.prototype._generateSortComparator):
+        (WI.ResourceCookiesContentView.prototype._refreshRequestCookiesSection):
+        (WI.ResourceCookiesContentView.prototype._refreshResponseCookiesSection):
+        (WI.ResourceCookiesContentView.prototype._sizeForTable):
+        (WI.ResourceCookiesContentView.prototype._resourceRequestHeadersDidChange):
+        (WI.ResourceCookiesContentView.prototype._resourceResponseReceived):
+        Tables for Request and Response cookies. They are simliar with slightly different columns.
+        Handle simple display and sorting for the tables.
+
+        * UserInterface/Views/ResourceHeadersContentView.js:
+        (WI.ResourceHeadersContentView.prototype._refreshResponseHeadersSection):
+        Break out Set-Cookie headers as multiple headers. THey should never be combined.
+
+        * UserInterface/Views/Table.js:
+        (WI.Table.prototype.showColumn):
+        (WI.Table.prototype.hideColumn):
+        (WI.Table.prototype._handleHeaderContextMenu):
+        * UserInterface/Views/TableColumn.js:
+        (WI.TableColumn.prototype.get hideable):
+        (WI.TableColumn.prototype.set hidden):
+        (WI.TableColumn):
+        (WI.TableColumn.prototype.setHidden): Deleted.
+        Make it so some columns can not be hidden. For example the "value" column
+        in the Cookie tables.
+
+2017-10-09  Joseph Pecoraro  <pecoraro@apple.com>
+
         Web Inspector: Network Tab - Search Headers Detail View
         https://bugs.webkit.org/show_bug.cgi?id=177981
 
index 915a249..fe73ded 100644 (file)
@@ -609,9 +609,11 @@ localizedStrings["No Watch Expressions"] = "No Watch Expressions";
 localizedStrings["No canvas contexts found"] = "No canvas contexts found";
 localizedStrings["No matching ARIA role"] = "No matching ARIA role";
 localizedStrings["No preview available"] = "No preview available";
+localizedStrings["No request cookies."] = "No request cookies.";
 localizedStrings["No request headers"] = "No request headers";
 localizedStrings["No request, served from the disk cache."] = "No request, served from the disk cache.";
 localizedStrings["No request, served from the memory cache."] = "No request, served from the memory cache.";
+localizedStrings["No response cookies."] = "No response cookies.";
 localizedStrings["No response headers"] = "No response headers";
 localizedStrings["Node"] = "Node";
 localizedStrings["Node Removed"] = "Node Removed";
@@ -724,6 +726,7 @@ localizedStrings["Repeating Linear Gradient"] = "Repeating Linear Gradient";
 localizedStrings["Repeating Radial Gradient"] = "Repeating Radial Gradient";
 localizedStrings["Request"] = "Request";
 localizedStrings["Request & Response"] = "Request & Response";
+localizedStrings["Request Cookies"] = "Request Cookies";
 localizedStrings["Request Data"] = "Request Data";
 localizedStrings["Request Headers"] = "Request Headers";
 localizedStrings["Requesting: %s"] = "Requesting: %s";
@@ -738,6 +741,7 @@ localizedStrings["Resource was loaded with the “data“ scheme."] = "Resource
 localizedStrings["Resource was served from the cache."] = "Resource was served from the cache.";
 localizedStrings["Resources"] = "Resources";
 localizedStrings["Response"] = "Response";
+localizedStrings["Response Cookies"] = "Response Cookies";
 localizedStrings["Response Headers"] = "Response Headers";
 localizedStrings["Restart (%s)"] = "Restart (%s)";
 localizedStrings["Restart animation"] = "Restart animation";
index 96a16f8..ae4a86d 100644 (file)
     <link rel="stylesheet" href="Views/RenderingFrameTimelineOverviewGraph.css">
     <link rel="stylesheet" href="Views/RenderingFrameTimelineView.css">
     <link rel="stylesheet" href="Views/Resizer.css">
+    <link rel="stylesheet" href="Views/ResourceCookiesContentView.css">
     <link rel="stylesheet" href="Views/ResourceDetailsSection.css">
     <link rel="stylesheet" href="Views/ResourceDetailsSidebarPanel.css">
     <link rel="stylesheet" href="Views/ResourceHeadersContentView.css">
     <script src="Models/CollectionTypes.js"></script>
     <script src="Models/Color.js"></script>
     <script src="Models/ConsoleCommandResultMessage.js"></script>
+    <script src="Models/Cookie.js"></script>
     <script src="Models/CookieStorageObject.js"></script>
     <script src="Models/DOMBreakpoint.js"></script>
     <script src="Models/DOMNode.js"></script>
     <script src="Views/ResourceDetailsSection.js"></script>
     <script src="Views/ResourceDetailsSidebarPanel.js"></script>
     <script src="Views/ResourceHeadersContentView.js"></script>
+    <script src="Views/ResourceCookiesContentView.js"></script>
     <script src="Views/ResourceSidebarPanel.js"></script>
     <script src="Views/ResourceTimelineDataGridNode.js"></script>
     <script src="Views/ResourceTimingPopoverDataGridNode.js"></script>
diff --git a/Source/WebInspectorUI/UserInterface/Models/Cookie.js b/Source/WebInspectorUI/UserInterface/Models/Cookie.js
new file mode 100644 (file)
index 0000000..3cfc06f
--- /dev/null
@@ -0,0 +1,175 @@
+/*
+ * Copyright (C) 2017 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER 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.
+ */
+
+WI.Cookie = class Cookie
+{
+    constructor(type, name, value, raw, expires, maxAge, path, domain, secure, httpOnly)
+    {
+        console.assert(Object.values(WI.Cookie.Type).includes(type));
+        console.assert(typeof name === "string");
+        console.assert(typeof value === "string");
+        console.assert(!raw || typeof raw === "string");
+        console.assert(!expires || expires instanceof Date);
+        console.assert(!maxAge || typeof maxAge === "number");
+        console.assert(!path || typeof path === "string");
+        console.assert(!domain || typeof domain === "string");
+        console.assert(!secure || typeof secure === "boolean");
+        console.assert(!httpOnly || typeof httpOnly === "boolean");
+
+        this.type = type;
+        this.name = name || "";
+        this.value = value || "";
+
+        if (this.type === WI.Cookie.Type.Response) {
+            this.rawHeader = raw || "";
+            this.expires = expires || null;
+            this.maxAge = maxAge || null;
+            this.path = path || null;
+            this.domain = domain || null;
+            this.secure = secure || false;
+            this.httpOnly = httpOnly || false;
+        }
+    }
+
+    // Static
+
+    // RFC 6265 defines the HTTP Cookie and Set-Cookie header fields:
+    // https://www.ietf.org/rfc/rfc6265.txt
+
+    static parseCookieRequestHeader(header)
+    {
+        if (!header)
+            return [];
+
+        header = header.trim();
+        if (!header)
+            return [];
+
+        let cookies = [];
+
+        // Cookie: <name> = <value> ( ";" SP <name> = <value> )*?
+        // NOTE: Just name/value pairs.
+
+        let pairs = header.split(/; /);
+        for (let pair of pairs) {
+            let match = pair.match(/^(?<name>[^\s=]+)[ \t]*=[ \t]*(?<value>.*)$/);
+            if (!match) {
+                WI.reportInternalError("Failed to parse Cookie pair", {header, pair});
+                continue;
+            }
+
+            let {name, value} = match.groups;
+            cookies.push(new WI.Cookie(WI.Cookie.Type.Request, name, value));
+        }
+
+        return cookies;
+    }
+
+    static parseSetCookieResponseHeader(header)
+    {
+        if (!header)
+            return null;
+
+        // Set-Cookie: <name> = <value> ( ";" SP <attr-maybe-pair> )*?
+        // NOTE: Some attributes can have pairs (e.g. "Path=/"), some are only a
+        // single word (e.g. "Secure").
+
+        // Parse name/value.
+        let nameValueMatch = header.match(/^(?<name>[^\s=]+)[ \t]*=[ \t]*(?<value>[^;]*)/);
+        if (!nameValueMatch) {
+            WI.reportInternalError("Failed to parse Set-Cookie header", {header});
+            return null;
+        }
+
+        let {name, value} = nameValueMatch.groups;
+        let expires = null;
+        let maxAge = null;
+        let path = null;
+        let domain = null;
+        let secure = false;
+        let httpOnly = false;
+
+        // Parse Attributes
+        let remaining = header.substr(nameValueMatch[0].length);
+        let attributes = remaining.split(/; ?/);
+        for (let attribute of attributes) {
+            if (!attribute)
+                continue;
+
+            let match = attribute.match(/^(?<name>[^\s=]+)(?:=(?<value>.*))?$/);
+            if (!match) {
+                console.error("Failed to parse Set-Cookie attribute:", attribute);
+                continue;
+            }
+
+            let attributeName = match.groups.name;
+            let attributeValue = match.groups.value;
+            switch (attributeName.toLowerCase()) {
+            case "expires":
+                console.assert(attributeValue);
+                expires = new Date(attributeValue);
+                if (isNaN(expires.getTime())) {
+                    console.warn("Invalid Expires date:", attributeValue);
+                    expires = null;
+                }
+                break;
+            case "max-age":
+                console.assert(attributeValue);
+                maxAge = parseInt(attributeValue, 10);
+                if (isNaN(maxAge) || !/^\d+$/.test(attributeValue)) {
+                    console.warn("Invalid MaxAge value:", attributeValue);
+                    maxAge = null;
+                }
+                break;
+            case "path":
+                console.assert(attributeValue);
+                path = attributeValue;
+                break;
+            case "domain":
+                console.assert(attributeValue);
+                domain = attributeValue;
+                break;
+            case "secure":
+                console.assert(!attributeValue);
+                secure = true;
+                break;
+            case "httponly":
+                console.assert(!attributeValue);
+                httpOnly = true;
+                break;
+            default:
+                console.warn("Unknown Cookie attribute:", attribute);
+                break;
+            }
+        }
+
+        return new WI.Cookie(WI.Cookie.Type.Response, name, value, header, expires, maxAge, path, domain, secure, httpOnly);
+    }
+}
+
+WI.Cookie.Type = {
+    Request: "request",
+    Response: "response",
+};
index 215a469..34fb982 100644 (file)
@@ -46,6 +46,8 @@ WI.Resource = class Resource extends WI.SourceCode
         this._requestData = requestData || null;
         this._requestHeaders = requestHeaders || {};
         this._responseHeaders = {};
+        this._requestCookies = null;
+        this._responseCookies = null;
         this._parentFrame = null;
         this._initiatorSourceCodeLocation = initiatorSourceCodeLocation || null;
         this._initiatedResources = [];
@@ -423,6 +425,36 @@ WI.Resource = class Resource extends WI.SourceCode
         return this._responseHeaders;
     }
 
+    get requestCookies()
+    {
+        if (!this._requestCookies)
+            this._requestCookies = WI.Cookie.parseCookieRequestHeader(this._requestHeaders.valueForCaseInsensitiveKey("Cookie"));
+
+        return this._requestCookies;
+    }
+
+    get responseCookies()
+    {
+        if (!this._responseCookies) {
+            // FIXME: The backend sends multiple "Set-Cookie" headers in one "Set-Cookie" with multiple values
+            // separated by ", ". This doesn't allow us to safely distinguish between a ", " that separates
+            // multiple headers or one that may be valid part of a Cookie's value or attribute, such as the
+            // ", " in the the date format "Expires=Tue, 03-Oct-2017 04:39:21 GMT". To improve heuristics
+            // we do a negative lookahead for numbers, but we can still fail on cookie values containing ", ".
+            let rawCombinedHeader = this._responseHeaders.valueForCaseInsensitiveKey("Set-Cookie") || "";
+            let setCookieHeaders = rawCombinedHeader.split(/, (?![0-9])/);
+            let cookies = [];
+            for (let header of setCookieHeaders) {
+                let cookie = WI.Cookie.parseSetCookieResponseHeader(header);
+                if (cookie)
+                    cookies.push(cookie);
+            }
+            this._responseCookies = cookies;
+        }
+
+        return this._responseCookies;
+    }
+
     get requestSentTimestamp()
     {
         return this._requestSentTimestamp;
@@ -605,6 +637,7 @@ WI.Resource = class Resource extends WI.SourceCode
 
         this._url = url;
         this._requestHeaders = requestHeaders || {};
+        this._requestCookies = null;
         this._lastRedirectReceivedTimestamp = elapsedTime || NaN;
 
         if (oldURL !== url) {
@@ -642,6 +675,7 @@ WI.Resource = class Resource extends WI.SourceCode
         this._statusCode = statusCode;
         this._statusText = statusText;
         this._responseHeaders = responseHeaders || {};
+        this._responseCookies = null;
         this._responseReceivedTimestamp = elapsedTime || NaN;
         this._timingData = WI.ResourceTimingData.fromPayload(timingData, this);
 
@@ -702,6 +736,7 @@ WI.Resource = class Resource extends WI.SourceCode
             this._connectionIdentifier = WI.Resource.connectionIdentifierFromPayload(metrics.connectionIdentifier);
         if (metrics.requestHeaders) {
             this._requestHeaders = metrics.requestHeaders;
+            this._requestCookies = null;
             this.dispatchEventToListeners(WI.Resource.Event.RequestHeadersDidChange);
         }
 
index bf34c91..dea731a 100644 (file)
     <script src="Models/CollectionEntryPreview.js"></script>
     <script src="Models/Color.js"></script>
     <script src="Models/ConsoleCommandResultMessage.js"></script>
+    <script src="Models/Cookie.js"></script>
     <script src="Models/CookieStorageObject.js"></script>
     <script src="Models/DOMBreakpoint.js"></script>
     <script src="Models/DOMNode.js"></script>
index d9b1c11..74e8275 100644 (file)
@@ -161,9 +161,8 @@ WI.NetworkResourceDetailView = class NetworkResourceDetailView extends WI.View
             this._contentBrowser.showContentView(this._headersContentView);
             break;
         case "cookies":
-            // FIXME: Provide a Resource Cookies View.
             if (!this._cookiesContentView)
-                this._cookiesContentView = new WI.DebugContentView("Cookies");
+                this._cookiesContentView = new WI.ResourceCookiesContentView(this._resource);
             this._contentBrowser.showContentView(this._cookiesContentView);
             break;
         case "timing":
index a41aeb4..f763793 100644 (file)
@@ -23,7 +23,7 @@
  * THE POSSIBILITY OF SUCH DAMAGE.
  */
 
-.content-view.network .table .icon {
+.content-view.network .network-table .icon {
     position: relative;
     width: 16px;
     height: 16px;
     -webkit-margin-end: 4px;
 }
 
-.content-view.network .table li:not(.filler) .cell.name {
+.network-table li:not(.filler) .cell.name {
     cursor: pointer;
 }
 
-.content-view.network .table .cache-type {
+.network-table .cache-type {
     color: var(--text-color-gray-medium);
 }
 
-.content-view.network .table .error {
+.network-table .error {
     color: var(--error-text-color);
 }
 
-body[dir=ltr] .content-view.network .table .cell.name > .status {
+body[dir=ltr] .network-table .cell.name > .status {
     float: right;
     margin-left: 4px;
 }
 
-body[dir=rtl] .content-view.network .table .cell.name > .status {
+body[dir=rtl] .network-table .cell.name > .status {
     float: left;
     margin-right: 4px;
 }
 
-.content-view.network .table .cell.name > .status .indeterminate-progress-spinner {
+.network-table .cell.name > .status .indeterminate-progress-spinner {
     margin-top: 3px;
     width: 14px;
     height: 14px;
 }
 
-.showing-detail .table .cell:not(.name) {
+.showing-detail .network-table .cell:not(.name) {
     display: none;
 }
 
-.showing-detail .table .resizer:not(:first-of-type) {
+.showing-detail .network-table .resizer:not(:first-of-type) {
     display: none;
 }
+
+.network-table :not(.header) .cell:first-of-type {
+    background: rgba(0, 0, 0, 0.07);
+}
diff --git a/Source/WebInspectorUI/UserInterface/Views/ResourceCookiesContentView.css b/Source/WebInspectorUI/UserInterface/Views/ResourceCookiesContentView.css
new file mode 100644 (file)
index 0000000..6ce9ff3
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2017 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER 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.
+ */
+
+.resource-cookies.resource-details > section > .details.has-table {
+    border-left: none;
+}
+
+.resource-cookies .table {
+    border: 1px solid var(--border-color);
+}
+
+.resource-cookies .table > .header {
+    -webkit-user-select: none;
+}
diff --git a/Source/WebInspectorUI/UserInterface/Views/ResourceCookiesContentView.js b/Source/WebInspectorUI/UserInterface/Views/ResourceCookiesContentView.js
new file mode 100644 (file)
index 0000000..0653933
--- /dev/null
@@ -0,0 +1,287 @@
+/*
+ * Copyright (C) 2017 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER 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.
+ */
+
+WI.ResourceCookiesContentView = class ResourceCookiesContentView extends WI.ContentView
+{
+    constructor(resource)
+    {
+        super(null);
+
+        console.assert(resource instanceof WI.Resource);
+
+        this._resource = resource;
+        this._resource.addEventListener(WI.Resource.Event.RequestHeadersDidChange, this._resourceRequestHeadersDidChange, this);
+        this._resource.addEventListener(WI.Resource.Event.ResponseReceived, this._resourceResponseReceived, this);
+
+        this.element.classList.add("resource-details", "resource-cookies");
+    }
+
+    // Table dataSource
+
+    tableNumberOfRows(table)
+    {
+        return this._dataSourceForTable(table).length;
+    }
+
+    tableSortChanged(table)
+    {
+        let sortComparator = this._generateSortComparator(table);
+        if (!sortComparator)
+            return;
+
+        let dataSource = this._dataSourceForTable(table);
+        dataSource.sort(sortComparator);
+        table.reloadData();
+    }
+
+    // Table delegate
+
+    tablePopulateCell(table, cell, column, rowIndex)
+    {
+        let cookie = this._dataSourceForTable(table)[rowIndex];
+
+        const checkmark = "\u2713";
+
+        switch (column.identifier) {
+        case "name":
+            cell.textContent = cookie.name;
+            break;
+        case "value":
+            cell.textContent = cookie.value;
+            break;
+        case "domain":
+            cell.textContent = cookie.domain || emDash;
+            break;
+        case "path":
+            cell.textContent = cookie.path || emDash;
+            break;
+        case "expires":
+            cell.textContent = cookie.expires ? cookie.expires.toLocaleString() : WI.UIString("Session");
+            break;
+        case "maxAge":
+            cell.textContent = cookie.maxAge || emDash;
+            break;
+        case "secure":
+            cell.textContent = cookie.secure ? checkmark : zeroWidthSpace;
+            break;
+        case "httpOnly":
+            cell.textContent = cookie.httpOnly ? checkmark : zeroWidthSpace;
+            break;
+        }
+
+        return cell;
+    }
+
+    // Protected
+
+    initialLayout()
+    {
+        super.initialLayout();
+
+        this._requestCookiesSection = new WI.ResourceDetailsSection(WI.UIString("Request Cookies"));
+        this.element.appendChild(this._requestCookiesSection.element);
+        this._refreshRequestCookiesSection();
+
+        this._responseCookiesSection = new WI.ResourceDetailsSection(WI.UIString("Response Cookies"));
+        this.element.appendChild(this._responseCookiesSection.element);
+        this._refreshResponseCookiesSection();
+    }
+
+    // Private
+
+    _markIncompleteSectionWithMessage(section, message)
+    {
+        section.toggleIncomplete(true);
+
+        let p = section.detailsElement.appendChild(document.createElement("p"));
+        p.textContent = message;
+    }
+
+    _markIncompleteSectionWithLoadingIndicator(section)
+    {
+        section.toggleIncomplete(true);
+
+        let p = section.detailsElement.appendChild(document.createElement("p"));
+        let spinner = new WI.IndeterminateProgressSpinner;
+        p.appendChild(spinner.element);
+    }
+
+    _dataSourceForTable(table)
+    {
+        return table === this._requestCookiesTable ? this._requestCookiesDataSource : this._responseCookiesDataSource;
+    }
+
+    _generateSortComparator(table)
+    {
+        let sortColumnIdentifier = table.sortColumnIdentifier;
+        if (!sortColumnIdentifier)
+            return null;
+
+        let comparator;
+
+        switch (sortColumnIdentifier) {
+        case "name":
+        case "value":
+        case "domain":
+        case "path":
+            // String.
+            comparator = (a, b) => (a[sortColumnIdentifier] || "").extendedLocaleCompare(b[sortColumnIdentifier] || "");
+            break;
+
+        case "maxAge":
+            // Number.
+            comparator = (a, b) => {
+                let aValue = a[sortColumnIdentifier];
+                if (isNaN(aValue))
+                    return 1;
+                let bValue = b[sortColumnIdentifier];
+                if (isNaN(bValue))
+                    return -1;
+                return aValue - bValue;
+            }
+            break;
+
+        case "httpOnly":
+        case "secure":
+            // Boolean.
+            comparator = (a, b) => (a[sortColumnIdentifier] - b[sortColumnIdentifier]);
+            break;
+
+        case "expires":
+            // Date.
+            comparator = (a, b) => {
+                let aExpires = a.expires;
+                if (!aExpires)
+                    return 1;
+                let bExpires = b.expires;
+                if (!bExpires)
+                    return -1;
+                return aExpires.getTime() - bExpires.getTime();
+            }
+            break;
+
+        default:
+            console.assert("Unexpected sort column", sortColumnIdentifier);
+            return null;
+        }
+
+        let reverseFactor = table.sortOrder === WI.Table.SortOrder.Ascending ? 1 : -1;
+        return (a, b) => reverseFactor * comparator(a, b);
+    }
+
+    _refreshRequestCookiesSection()
+    {
+        let detailsElement = this._requestCookiesSection.detailsElement;
+        detailsElement.removeChildren();
+
+        if (this._resource.responseSource === WI.Resource.ResponseSource.MemoryCache) {
+            this._markIncompleteSectionWithMessage(this._requestCookiesSection, WI.UIString("No request, served from the memory cache."));
+            return;
+        }
+
+        this._requestCookiesDataSource = this._resource.requestCookies;
+
+        if (!this._requestCookiesTable) {
+            this._requestCookiesTable = new WI.Table("request-cookies", this, this, 20);
+            this._requestCookiesTable.addColumn(new WI.TableColumn("name", WI.UIString("Name"), {minWidth: 150, maxWidth: 300, initialWidth: 200, resizeType: WI.TableColumn.ResizeType.Locked}));
+            this._requestCookiesTable.addColumn(new WI.TableColumn("value", WI.UIString("Value"), {minWidth: 150, hideable: false}));
+            if (!this._requestCookiesTable.sortColumnIdentifier) {
+                this._requestCookiesTable.sortOrder = WI.Table.SortOrder.Ascending;
+                this._requestCookiesTable.sortColumnIdentifier = "name";
+            }
+        }
+
+        if (!this._requestCookiesDataSource.length) {
+            if (this._requestCookiesTable.isAttached)
+                this.removeSubview(this._requestCookiesTable);
+            this._markIncompleteSectionWithMessage(this._requestCookiesSection, WI.UIString("No request cookies."));
+        } else {
+            this._requestCookiesSection.toggleIncomplete(false);
+            this._requestCookiesTable.element.style.height = this._sizeForTable(this._requestCookiesTable) + "px";
+            this.addSubview(this._requestCookiesTable);
+            detailsElement.classList.add("has-table");
+            detailsElement.appendChild(this._requestCookiesTable.element);
+        }
+    }
+
+    _refreshResponseCookiesSection()
+    {
+        let detailsElement = this._responseCookiesSection.detailsElement;
+        detailsElement.removeChildren();
+
+        if (!this._resource.hasResponse()) {
+            this._markIncompleteSectionWithLoadingIndicator(this._responseCookiesSection);
+            return;
+        }
+
+        this._responseCookiesDataSource = this._resource.responseCookies;
+
+        if (!this._responseCookiesTable) {
+            this._responseCookiesTable = new WI.Table("request-cookies", this, this, 20);
+            this._responseCookiesTable.addColumn(new WI.TableColumn("name", WI.UIString("Name"), {minWidth: 150, maxWidth: 300, initialWidth: 200, resizeType: WI.TableColumn.ResizeType.Locked}));
+            this._responseCookiesTable.addColumn(new WI.TableColumn("value", WI.UIString("Value"), {minWidth: 150, hideable: false}));
+            this._responseCookiesTable.addColumn(new WI.TableColumn("domain", WI.unlocalizedString("Domain"), {}));
+            this._responseCookiesTable.addColumn(new WI.TableColumn("path", WI.unlocalizedString("Path"), {}));
+            this._responseCookiesTable.addColumn(new WI.TableColumn("expires", WI.unlocalizedString("Expires"), {maxWidth: 150}));
+            this._responseCookiesTable.addColumn(new WI.TableColumn("maxAge", WI.unlocalizedString("Max-Age"), {maxWidth: 90, align: "right"}));
+            this._responseCookiesTable.addColumn(new WI.TableColumn("secure", WI.unlocalizedString("Secure"), {minWidth: 55, maxWidth: 65, align: "center"}));
+            this._responseCookiesTable.addColumn(new WI.TableColumn("httpOnly", WI.unlocalizedString("HttpOnly"), {minWidth: 55, maxWidth: 65, align: "center"}));
+            if (!this._responseCookiesTable.sortColumnIdentifier) {
+                this._responseCookiesTable.sortOrder = WI.Table.SortOrder.Ascending;
+                this._responseCookiesTable.sortColumnIdentifier = "name";
+            }
+        }
+
+        if (!this._responseCookiesDataSource.length) {
+            if (this._responseCookiesTable.isAttached)
+                this.removeSubview(this._responseCookiesTable);
+            this._markIncompleteSectionWithMessage(this._responseCookiesSection, WI.UIString("No response cookies."));
+        } else {
+            this._responseCookiesSection.toggleIncomplete(false);
+            this._responseCookiesTable.element.style.height = this._sizeForTable(this._responseCookiesTable) + "px";
+            this.addSubview(this._responseCookiesTable);
+            detailsElement.classList.add("has-table");
+            detailsElement.appendChild(this._responseCookiesTable.element);
+        }
+    }
+
+    _sizeForTable(table)
+    {
+        const headerHeight = 28;
+        const borderHeight = 3;
+        let rowsHeight = this._dataSourceForTable(table).length * table.rowHeight;
+        return rowsHeight + headerHeight + borderHeight;
+    }
+
+    _resourceRequestHeadersDidChange(event)
+    {
+        this._refreshRequestCookiesSection();
+    }
+
+    _resourceResponseReceived(event)
+    {
+        this._refreshResponseCookiesSection();
+    }
+};
index 18604b0..8c83c15 100644 (file)
@@ -201,7 +201,7 @@ WI.ResourceHeadersContentView = class ResourceHeadersContentView extends WI.Cont
 
     // Private
 
-    _incompleteSectionWithMessage(section, message)
+    _markIncompleteSectionWithMessage(section, message)
     {
         section.toggleIncomplete(true);
 
@@ -209,7 +209,7 @@ WI.ResourceHeadersContentView = class ResourceHeadersContentView extends WI.Cont
         p.textContent = message;
     }
 
-    _incompleteSectionWithLoadingIndicator(section)
+    _markIncompleteSectionWithLoadingIndicator(section)
     {
         section.toggleIncomplete(true);
 
@@ -282,11 +282,11 @@ WI.ResourceHeadersContentView = class ResourceHeadersContentView extends WI.Cont
         // A revalidation request still sends a request even though we served from cache, so show the request.
         if (this._resource.statusCode !== 304) {
             if (this._resource.responseSource === WI.Resource.ResponseSource.MemoryCache) {
-                this._incompleteSectionWithMessage(this._requestHeadersSection, WI.UIString("No request, served from the memory cache."));
+                this._markIncompleteSectionWithMessage(this._requestHeadersSection, WI.UIString("No request, served from the memory cache."));
                 return;
             }
             if (this._resource.responseSource === WI.Resource.ResponseSource.DiskCache) {
-                this._incompleteSectionWithMessage(this._requestHeadersSection, WI.UIString("No request, served from the disk cache."));
+                this._markIncompleteSectionWithMessage(this._requestHeadersSection, WI.UIString("No request, served from the disk cache."));
                 return;
             }
         }
@@ -312,7 +312,7 @@ WI.ResourceHeadersContentView = class ResourceHeadersContentView extends WI.Cont
             this._appendKeyValuePair(detailsElement, key, requestHeaders[key], "header");
 
         if (!detailsElement.firstChild)
-            this._incompleteSectionWithMessage(this._requestHeadersSection, WI.UIString("No request headers"));
+            this._markIncompleteSectionWithMessage(this._requestHeadersSection, WI.UIString("No request headers"));
     }
 
     _refreshResponseHeadersSection()
@@ -321,7 +321,7 @@ WI.ResourceHeadersContentView = class ResourceHeadersContentView extends WI.Cont
         detailsElement.removeChildren();
 
         if (!this._resource.hasResponse()) {
-            this._incompleteSectionWithLoadingIndicator(this._responseHeadersSection);
+            this._markIncompleteSectionWithLoadingIndicator(this._responseHeadersSection);
             return;
         }
 
@@ -340,11 +340,21 @@ WI.ResourceHeadersContentView = class ResourceHeadersContentView extends WI.Cont
         }
 
         let responseHeaders = this._resource.responseHeaders;
-        for (let key in responseHeaders)
+        for (let key in responseHeaders) {
+            // Split multiple Set-Cookie response headers out into their multiple headers instead of as a combined value.
+            if (key.toLowerCase() === "set-cookie") {
+                let responseCookies = this._resource.responseCookies;
+                console.assert(responseCookies.length > 0);
+                for (let cookie of responseCookies)
+                    this._appendKeyValuePair(detailsElement, key, cookie.rawHeader, "header");
+                continue;
+            }
+
             this._appendKeyValuePair(detailsElement, key, responseHeaders[key], "header");
+        }
 
         if (!detailsElement.firstChild)
-            this._incompleteSectionWithMessage(this._responseHeadersSection, WI.UIString("No response headers"));
+            this._markIncompleteSectionWithMessage(this._responseHeadersSection, WI.UIString("No response headers"));
     }
 
     _refreshQueryStringSection()
index c2dd22f..47385b5 100644 (file)
@@ -160,10 +160,6 @@ body[dir=rtl] .table .cell:first-child {
     border-right: var(--table-column-border-start);
 }
 
-.table :not(.header) .cell:first-of-type {
-    background: rgba(0, 0, 0, 0.07);
-}
-
 .table .cell.align-right {
     text-align: right;
 }
index 61f2dfb..6812dc4 100644 (file)
@@ -44,7 +44,7 @@ WI.Table = class Table extends WI.View
         // synchronized scrolling between multiple elements, or making `position: sticky`
         // respect different vertical / horizontal scroll containers.
 
-        this.element.classList.add("table");
+        this.element.classList.add("table", identifier);
         this.element.tabIndex = 0;
         this.element.addEventListener("keydown", this._handleKeyDown.bind(this));
 
@@ -334,7 +334,7 @@ WI.Table = class Table extends WI.View
         if (!column.hidden)
             return;
 
-        column.setHidden(false);
+        column.hidden = false;
 
         let columnIndex = this._hiddenColumns.indexOf(column);
         this._hiddenColumns.splice(columnIndex, 1);
@@ -388,10 +388,14 @@ WI.Table = class Table extends WI.View
         if (column.locked)
             return;
 
+        console.assert(column.hideable, "Column is not hideable so should always be shown.");
+        if (!column.hideable)
+            return;
+
         if (column.hidden)
             return;
 
-        column.setHidden(true);
+        column.hidden = true;
 
         this._hiddenColumns.push(column);
 
@@ -1174,6 +1178,8 @@ WI.Table = class Table extends WI.View
         for (let [columnIdentifier, column] of this._columnSpecs) {
             if (column.locked)
                 continue;
+            if (!column.hideable)
+                continue;
 
             let checked = !column.hidden;
             contextMenu.appendCheckboxItem(column.name, () => {
index d021526..086b565 100644 (file)
@@ -25,7 +25,7 @@
 
 WI.TableColumn = class TableColumn extends WI.Object
 {
-    constructor(identifier, name, {initialWidth, minWidth, maxWidth, hidden, sortable, align, resizeType} = {})
+    constructor(identifier, name, {initialWidth, minWidth, maxWidth, hidden, sortable, hideable, align, resizeType} = {})
     {
         super();
 
@@ -44,6 +44,7 @@ WI.TableColumn = class TableColumn extends WI.Object
         this._hidden = hidden || false;
         this._defaultHidden = hidden || false;
         this._sortable = typeof sortable === "boolean" ? sortable : true;
+        this._hideable = typeof hideable === "boolean" ? hideable : true;
         this._align = align || null;
         this._resizeType = resizeType || TableColumn.ResizeType.Auto;
 
@@ -60,6 +61,7 @@ WI.TableColumn = class TableColumn extends WI.Object
     get maxWidth() { return this._maxWidth; }
     get defaultHidden() { return this._defaultHidden; }
     get sortable() { return this._sortable; }
+    get hideable() { return this._hideable; }
     get align() { return this._align; }
 
     get locked() { return this._resizeType === TableColumn.ResizeType.Locked; }
@@ -90,8 +92,9 @@ WI.TableColumn = class TableColumn extends WI.Object
         return this._hidden;
     }
 
-    setHidden(x)
+    set hidden(x)
     {
+        console.assert(!this.locked && this._hideable, "Should not be able to hide a non-hideable column.");
         this._hidden = x;
     }
 };