| // Copyright 2026 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import type * as Protocol from '../../generated/protocol.js'; |
| import {createTarget} from '../../testing/EnvironmentHelpers.js'; |
| import {describeWithMockConnection} from '../../testing/MockConnection.js'; |
| import type {TreeElement} from '../../ui/legacy/Treeoutline.js'; |
| |
| import * as Application from './application.js'; |
| import type {ResourcesPanel} from './ResourcesPanel.js'; |
| |
| describeWithMockConnection('DeviceBoundSessionsTreeElement', () => { |
| let model: Application.DeviceBoundSessionsModel.DeviceBoundSessionsModel; |
| let target: SDK.Target.Target; |
| let mockPanel: ResourcesPanel; |
| |
| function makeSession(site: string, sessionId: string): Protocol.Network.DeviceBoundSession { |
| return { |
| key: {site, id: sessionId}, |
| refreshUrl: 'https://example1.com/refresh', |
| inclusionRules: {origin: 'https://example1.com', includeSite: true, urlRules: []}, |
| cookieCravings: [], |
| expiryDate: 1767225600, |
| allowedRefreshInitiators: [], |
| }; |
| } |
| |
| beforeEach(() => { |
| target = createTarget(); |
| const networkManager = target.model(SDK.NetworkManager.NetworkManager)!; |
| model = new Application.DeviceBoundSessionsModel.DeviceBoundSessionsModel(); |
| model.modelAdded(networkManager); |
| mockPanel = {} as unknown as ResourcesPanel; |
| }); |
| |
| it('builds the correct tree hierarchy on INITIALIZE_SESSIONS event only for visible sites', () => { |
| const root = new Application.DeviceBoundSessionsTreeElement.RootTreeElement(mockPanel, model); |
| root.onbind(); |
| |
| model.addVisibleSite('example1.com'); |
| model.addVisibleSite('example2.com'); |
| |
| const sessions = [ |
| makeSession('example1.com', 'session_1'), |
| makeSession('example1.com', 'session_2'), |
| makeSession('example2.com', 'session_3'), |
| makeSession('hidden.com', 'session_4'), |
| ]; |
| |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.INITIALIZE_SESSIONS, {sessions}); |
| |
| // Verify site nodes. |
| assert.strictEqual(root.childCount(), 2); |
| const site1Node = root.children()[0]; |
| const site2Node = root.children()[1]; |
| assert.strictEqual(site1Node.title, 'example1.com'); |
| assert.strictEqual(site2Node.title, 'example2.com'); |
| |
| // Verify session nodes under example1.com. |
| assert.strictEqual(site1Node.childCount(), 2); |
| assert.strictEqual(site1Node.children()[0].title, 'session_1'); |
| assert.strictEqual(site1Node.children()[1].title, 'session_2'); |
| // Verify session nodes under example2.com. |
| assert.strictEqual(site2Node.childCount(), 1); |
| assert.strictEqual(site2Node.children()[0].title, 'session_3'); |
| }); |
| |
| it('adds a tree element when a site becomes visible after data is already loaded', () => { |
| const root = new Application.DeviceBoundSessionsTreeElement.RootTreeElement(mockPanel, model); |
| root.onbind(); |
| |
| const sessions = [ |
| makeSession('example1.com', 'session_1'), |
| makeSession('example2.com', 'session_1'), |
| makeSession('example2.com', 'session_2'), |
| makeSession('hidden.com', 'session_3'), |
| ]; |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.INITIALIZE_SESSIONS, {sessions}); |
| assert.strictEqual(root.childCount(), 0); |
| |
| model.addVisibleSite('example1.com'); |
| model.addVisibleSite('example2.com'); |
| assert.strictEqual(root.childCount(), 2); |
| const siteNode1 = root.children()[0]; |
| assert.strictEqual(siteNode1.title, 'example1.com'); |
| assert.strictEqual(siteNode1.childCount(), 1); |
| assert.strictEqual(siteNode1.children()[0].title, 'session_1'); |
| const siteNode2 = root.children()[1]; |
| assert.strictEqual(siteNode2.title, 'example2.com'); |
| assert.strictEqual(siteNode2.childCount(), 2); |
| assert.strictEqual(siteNode2.children()[0].title, 'session_1'); |
| assert.strictEqual(siteNode2.children()[1].title, 'session_2'); |
| }); |
| |
| it('removes empty tree elements on CLEAR_EVENTS', () => { |
| const root = new Application.DeviceBoundSessionsTreeElement.RootTreeElement(mockPanel, model); |
| root.onbind(); |
| model.addVisibleSite('example1.com'); |
| model.addVisibleSite('example2.com'); |
| |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.EVENT_OCCURRED, |
| {site: 'example1.com', sessionId: undefined}); |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.EVENT_OCCURRED, |
| {site: 'example1.com', sessionId: 'session_1'}); |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.EVENT_OCCURRED, |
| {site: 'example1.com', sessionId: 'session_2'}); |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.EVENT_OCCURRED, |
| {site: 'example2.com', sessionId: 'session_1'}); |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.EVENT_OCCURRED, |
| {site: 'hidden.com', sessionId: 'session_1'}); |
| |
| assert.strictEqual(root.childCount(), 2); |
| const siteNode1 = root.children()[0]; |
| const siteNode2 = root.children()[1]; |
| assert.strictEqual(siteNode1.title, 'example1.com'); |
| assert.strictEqual(siteNode2.title, 'example2.com'); |
| assert.strictEqual(siteNode1.childCount(), 3); |
| assert.strictEqual(siteNode2.childCount(), 1); |
| assert.strictEqual(siteNode1.children()[0].title, 'No session'); |
| assert.strictEqual(siteNode1.children()[1].title, 'session_1'); |
| assert.strictEqual(siteNode1.children()[2].title, 'session_2'); |
| assert.strictEqual(siteNode2.children()[0].title, 'session_1'); |
| |
| model.dispatchEventToListeners(Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.CLEAR_EVENTS, { |
| emptySessions: new Map([ |
| ['example1.com', [undefined, 'session_1']], |
| ['example2.com', ['session_1']], |
| ['hidden.com', ['session_1']], |
| ]), |
| emptySites: new Set(['example2.com', 'hidden.com']), |
| noLongerFailedSessions: new Map(), |
| }); |
| |
| model.addVisibleSite('hidden.com'); |
| |
| assert.strictEqual(root.childCount(), 1); |
| const siteNode = root.children()[0]; |
| assert.strictEqual(siteNode.title, 'example1.com'); |
| assert.strictEqual(siteNode.children()[0].title, 'session_2'); |
| }); |
| |
| it('removes a tree element if visible sites are cleared', () => { |
| const root = new Application.DeviceBoundSessionsTreeElement.RootTreeElement(mockPanel, model); |
| root.onbind(); |
| |
| model.addVisibleSite('example.com'); |
| const session = makeSession('example.com', 'session_1'); |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.INITIALIZE_SESSIONS, {sessions: [session]}); |
| assert.strictEqual(root.childCount(), 1); |
| model.clearVisibleSites(); |
| assert.strictEqual(root.childCount(), 0); |
| }); |
| |
| it('does not duplicate sites or sessions if data is re-sent', () => { |
| const root = new Application.DeviceBoundSessionsTreeElement.RootTreeElement(mockPanel, model); |
| root.onbind(); |
| |
| model.addVisibleSite('example1.com'); |
| const session = makeSession('example1.com', 'session_1'); |
| |
| // First event. |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.INITIALIZE_SESSIONS, {sessions: [session]}); |
| assert.strictEqual(root.childCount(), 1); |
| assert.strictEqual(root.children()[0].childCount(), 1); |
| |
| // Second event. |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.INITIALIZE_SESSIONS, {sessions: [session]}); |
| assert.strictEqual(root.childCount(), 1); |
| assert.strictEqual(root.children()[0].childCount(), 1); |
| }); |
| |
| it('shows the details view when a session tree element is selected', () => { |
| const showSessionSpy = sinon.spy(); |
| mockPanel.showDeviceBoundSession = showSessionSpy; |
| const root = new Application.DeviceBoundSessionsTreeElement.RootTreeElement(mockPanel, model); |
| root.onbind(); |
| |
| model.addVisibleSite('example.com'); |
| const session = makeSession('example.com', 'session-123'); |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.INITIALIZE_SESSIONS, {sessions: [session]}); |
| |
| assert.strictEqual(root.childCount(), 1); |
| const siteNode = root.children()[0]; |
| assert.strictEqual(siteNode.childCount(), 1); |
| const sessionNode = siteNode.children()[0]; |
| sessionNode.onselect(false); |
| |
| sinon.assert.calledOnce(showSessionSpy); |
| sinon.assert.calledWith(showSessionSpy, model, 'example.com', 'session-123'); |
| }); |
| |
| it('shows the default view when a site tree element is selected', () => { |
| const showDefaultSpy = sinon.spy(); |
| mockPanel.showDeviceBoundSessionDefault = showDefaultSpy; |
| const root = new Application.DeviceBoundSessionsTreeElement.RootTreeElement(mockPanel, model); |
| root.onbind(); |
| |
| model.addVisibleSite('example.com'); |
| const session = makeSession('example.com', 'session-123'); |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.INITIALIZE_SESSIONS, {sessions: [session]}); |
| |
| assert.strictEqual(root.childCount(), 1); |
| const siteNode = root.children()[0]; |
| siteNode.onselect(false); |
| |
| sinon.assert.calledOnce(showDefaultSpy); |
| sinon.assert.calledWith( |
| showDefaultSpy, model, 'Device bound sessions', |
| 'On this page you can view device bound sessions and associated events'); |
| }); |
| |
| it('shows the default view when the root tree element is selected', () => { |
| const showDefaultSpy = sinon.spy(); |
| mockPanel.showDeviceBoundSessionDefault = showDefaultSpy; |
| const root = new Application.DeviceBoundSessionsTreeElement.RootTreeElement(mockPanel, model); |
| root.onbind(); |
| |
| root.onselect(false); |
| |
| sinon.assert.calledOnce(showDefaultSpy); |
| sinon.assert.calledWith( |
| showDefaultSpy, model, 'Device bound sessions', |
| 'On this page you can view device bound sessions and associated events'); |
| }); |
| |
| it('adds a tree element when EVENT_OCCURRED fires for a new session', () => { |
| const root = new Application.DeviceBoundSessionsTreeElement.RootTreeElement(mockPanel, model); |
| root.onbind(); |
| |
| model.addVisibleSite('example1.com'); |
| model.addVisibleSite('example2.com'); |
| assert.strictEqual(root.childCount(), 0); |
| |
| // An event occurs that adds a new site + session. |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.EVENT_OCCURRED, |
| {site: 'example1.com', sessionId: 'session_1'}); |
| assert.strictEqual(root.childCount(), 1); |
| const siteNode1 = root.children()[0]; |
| assert.strictEqual(siteNode1.title, 'example1.com'); |
| assert.strictEqual(siteNode1.childCount(), 1); |
| assert.strictEqual(siteNode1.children()[0].title, 'session_1'); |
| |
| // An event occurs that adds a new session to the existing site. |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.EVENT_OCCURRED, |
| {site: 'example1.com', sessionId: 'session_2'}); |
| assert.strictEqual(root.childCount(), 1); |
| const siteNode2 = root.children()[0]; |
| assert.strictEqual(siteNode2.title, 'example1.com'); |
| assert.strictEqual(siteNode2.childCount(), 2); |
| assert.strictEqual(siteNode2.children()[0].title, 'session_1'); |
| assert.strictEqual(siteNode2.children()[1].title, 'session_2'); |
| |
| // An event occurs that adds a "no session" to the existing site. |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.EVENT_OCCURRED, {site: 'example1.com'}); |
| assert.strictEqual(root.childCount(), 1); |
| const siteNode3 = root.children()[0]; |
| assert.strictEqual(siteNode3.title, 'example1.com'); |
| assert.strictEqual(siteNode3.childCount(), 3); |
| assert.strictEqual(siteNode3.children()[0].title, 'No session'); |
| assert.isTrue(siteNode3.children()[0].listItemElement.classList.contains('no-device-bound-session')); |
| assert.strictEqual(siteNode3.children()[1].title, 'session_1'); |
| assert.isFalse(siteNode3.children()[1].listItemElement.classList.contains('no-device-bound-session')); |
| assert.strictEqual(siteNode3.children()[2].title, 'session_2'); |
| assert.isFalse(siteNode3.children()[2].listItemElement.classList.contains('no-device-bound-session')); |
| |
| // An event occurs that adds a "no session" to a new site. |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.EVENT_OCCURRED, {site: 'example2.com'}); |
| assert.strictEqual(root.childCount(), 2); |
| assert.strictEqual(root.children()[0].title, 'example1.com'); |
| const siteNode4 = root.children()[1]; |
| assert.strictEqual(siteNode4.title, 'example2.com'); |
| assert.strictEqual(siteNode4.childCount(), 1); |
| assert.strictEqual(siteNode4.children()[0].title, 'No session'); |
| assert.isTrue(siteNode4.children()[0].listItemElement.classList.contains('no-device-bound-session')); |
| |
| // An event occurs that adds a new session + site but it is not visible. |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.EVENT_OCCURRED, |
| {site: 'hidden.com', sessionId: 'hidden_session'}); |
| assert.strictEqual(root.childCount(), 2); |
| assert.strictEqual(root.children()[0].title, 'example1.com'); |
| assert.strictEqual(root.children()[1].title, 'example2.com'); |
| }); |
| |
| it('updates the session tree element visual state when a session is terminated', () => { |
| const root = new Application.DeviceBoundSessionsTreeElement.RootTreeElement(mockPanel, model); |
| root.onbind(); |
| |
| const site = 'example.com'; |
| const sessionId = 'session_1'; |
| const otherSessionId = 'session_2'; |
| model.addVisibleSite(site); |
| |
| const session1 = makeSession(site, sessionId); |
| const session2 = makeSession(site, otherSessionId); |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.INITIALIZE_SESSIONS, |
| {sessions: [session1, session2]}); |
| const siteNode = root.children()[0]; |
| const session1Node = siteNode.children()[0]; |
| const session2Node = siteNode.children()[1]; |
| |
| // Initially is not terminated. |
| assert.isFalse(session1Node.listItemElement.classList.contains('device-bound-session-terminated')); |
| assert.isFalse(session2Node.listItemElement.classList.contains('device-bound-session-terminated')); |
| |
| // Simulate termination event. |
| const isSessionTerminatedStub = sinon.stub(model, 'isSessionTerminated'); |
| isSessionTerminatedStub.withArgs(site, sessionId).returns(true); |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.EVENT_OCCURRED, {site, sessionId}); |
| |
| // Session 1 should now be terminated. Session 2 remains unterminated. |
| assert.isTrue(session1Node.listItemElement.classList.contains('device-bound-session-terminated')); |
| assert.strictEqual(session1Node.listItemElement.getAttribute('aria-label'), 'session_1, Session terminated'); |
| assert.isFalse(session2Node.listItemElement.classList.contains('device-bound-session-terminated')); |
| assert.strictEqual(session2Node.listItemElement.getAttribute('aria-label'), 'session_2'); |
| |
| // Simulate recreation event. |
| isSessionTerminatedStub.withArgs(site, sessionId).returns(false); |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.EVENT_OCCURRED, {site, sessionId}); |
| |
| // Session 1 should no longer be terminated. |
| assert.isFalse(session1Node.listItemElement.classList.contains('device-bound-session-terminated')); |
| assert.strictEqual(session1Node.listItemElement.getAttribute('aria-label'), 'session_1'); |
| assert.isFalse(session2Node.listItemElement.classList.contains('device-bound-session-terminated')); |
| }); |
| |
| it('updates the session tree element visual state when a session has errors', () => { |
| const root = new Application.DeviceBoundSessionsTreeElement.RootTreeElement(mockPanel, model); |
| root.onbind(); |
| |
| const site = 'example.com'; |
| const sessionId = 'session_1'; |
| const sessionId2 = 'session_2'; |
| model.addVisibleSite(site); |
| |
| const session = makeSession(site, sessionId); |
| const session2 = makeSession(site, sessionId2); |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.INITIALIZE_SESSIONS, |
| {sessions: [session, session2]}); |
| const siteNode = root.children()[0]; |
| const sessionNode = siteNode.children()[0]; |
| const sessionNode2 = siteNode.children()[1]; |
| |
| function checkIcon(node: TreeElement, expectedIcon: string) { |
| const icon = node.listItemElement.querySelector('devtools-icon'); |
| assert.exists(icon); |
| assert.strictEqual(icon.getAttribute('name'), expectedIcon); |
| } |
| |
| // Initially has database icon. |
| checkIcon(sessionNode, 'database'); |
| assert.strictEqual(sessionNode.listItemElement.getAttribute('aria-label'), 'session_1'); |
| checkIcon(sessionNode2, 'database'); |
| assert.strictEqual(sessionNode2.listItemElement.getAttribute('aria-label'), 'session_2'); |
| |
| // A failed event should change it to a warning icon. |
| const sessionHasErrorsStub = sinon.stub(model, 'sessionHasErrors'); |
| sessionHasErrorsStub.withArgs(site, sessionId).returns(true); |
| model.dispatchEventToListeners( |
| Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.EVENT_OCCURRED, {site, sessionId}); |
| checkIcon(sessionNode, 'warning'); |
| assert.strictEqual(sessionNode.listItemElement.getAttribute('aria-label'), 'session_1, Session has errors'); |
| checkIcon(sessionNode2, 'database'); |
| assert.strictEqual(sessionNode2.listItemElement.getAttribute('aria-label'), 'session_2'); |
| |
| // Clearing events should change it back to a database icon. |
| sessionHasErrorsStub.withArgs(site, sessionId).returns(false); |
| model.dispatchEventToListeners(Application.DeviceBoundSessionsModel.DeviceBoundSessionModelEvents.CLEAR_EVENTS, { |
| emptySessions: new Map(), |
| emptySites: new Set(), |
| noLongerFailedSessions: new Map([[site, [sessionId]]]), |
| }); |
| checkIcon(sessionNode, 'database'); |
| assert.strictEqual(sessionNode.listItemElement.getAttribute('aria-label'), 'session_1'); |
| checkIcon(sessionNode2, 'database'); |
| }); |
| }); |