blob: 7677921d8841a2b71854a69c8bcb540304d32cd5 [file] [log] [blame]
// Copyright 2021 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 Protocol from '../../generated/protocol.js';
import {
renderElementIntoDOM,
} from '../../testing/DOMHelpers.js';
import {setupLocaleHooks} from '../../testing/LocaleHelpers.js';
import type * as UI from '../../ui/legacy/legacy.js';
import * as Application from './application.js';
async function renderOriginTrialTreeView(
data: Application.OriginTrialTreeView.OriginTrialTreeViewData,
): Promise<Application.OriginTrialTreeView.OriginTrialTreeView> {
const component = new Application.OriginTrialTreeView.OriginTrialTreeView();
component.data = data;
renderElementIntoDOM(component);
await component.updateComplete;
return component;
}
/**
* Extract `TreeOutline` component from `OriginTrialTreeView` for inspection.
*/
async function renderOriginTrialTreeViewTreeOutline(
data: Application.OriginTrialTreeView.OriginTrialTreeViewData,
): Promise<{
component: UI.TreeOutline.TreeElement,
shadowRoot: ShadowRoot,
}> {
const component = await renderOriginTrialTreeView(data);
const treeOutline = component.contentElement.querySelector<UI.TreeOutline.TreeViewElement>('devtools-tree')!;
assert.isNotNull(treeOutline.shadowRoot);
return {
component: treeOutline.getInternalTreeOutlineForTest().rootElement(),
shadowRoot: treeOutline.shadowRoot,
};
}
const tokenPlaceHolder = 'Origin Trial Token Placeholder';
const trialWithMultipleTokens: Protocol.Page.OriginTrial = {
trialName: 'AppCache',
status: Protocol.Page.OriginTrialStatus.Enabled,
tokensWithStatus: [
{
status: Protocol.Page.OriginTrialTokenStatus.Success,
rawTokenText: tokenPlaceHolder,
parsedToken: {
trialName: 'AppCache',
origin: 'https://foo.com',
expiryTime: 1000,
usageRestriction: Protocol.Page.OriginTrialUsageRestriction.None,
isThirdParty: false,
matchSubDomains: false,
},
},
{
status: Protocol.Page.OriginTrialTokenStatus.Expired,
rawTokenText: tokenPlaceHolder,
parsedToken: {
trialName: 'AppCache',
origin: 'https://foo.com',
expiryTime: 1000,
usageRestriction: Protocol.Page.OriginTrialUsageRestriction.None,
isThirdParty: false,
matchSubDomains: false,
},
},
{
status: Protocol.Page.OriginTrialTokenStatus.WrongOrigin,
rawTokenText: tokenPlaceHolder,
parsedToken: {
trialName: 'AppCache',
origin: 'https://bar.com',
expiryTime: 1000,
usageRestriction: Protocol.Page.OriginTrialUsageRestriction.None,
isThirdParty: false,
matchSubDomains: false,
},
},
],
};
const trialWithSingleToken: Protocol.Page.OriginTrial = {
trialName: 'AutoPictureInPicture',
status: Protocol.Page.OriginTrialStatus.ValidTokenNotProvided,
tokensWithStatus: [
{
status: Protocol.Page.OriginTrialTokenStatus.NotSupported,
rawTokenText: tokenPlaceHolder,
parsedToken: {
trialName: 'AutoPictureInPicture',
origin: 'https://foo.com',
expiryTime: 1000,
usageRestriction: Protocol.Page.OriginTrialUsageRestriction.None,
isThirdParty: false,
matchSubDomains: false,
},
},
],
};
const trialWithUnparsableToken: Protocol.Page.OriginTrial = {
trialName: 'UNKNOWN',
status: Protocol.Page.OriginTrialStatus.ValidTokenNotProvided,
tokensWithStatus: [
{
status: Protocol.Page.OriginTrialTokenStatus.InvalidSignature,
rawTokenText: tokenPlaceHolder,
},
],
};
function extractBadgeTextFromTreeNode(node: HTMLLIElement): string[] {
return [...node.querySelectorAll('devtools-adorner')]
.filter(adornerElement => adornerElement.checkVisibility())
.map(adornerElement => {
return adornerElement.deepInnerText();
});
}
interface VisibleTreeNodeFromDOM {
nodeElement: HTMLLIElement;
children?: VisibleTreeNodeFromDOM[];
}
/**
* Converts the nodes into a tree structure that we can assert against.
*/
function visibleNodesToTree(shadowRoot: ShadowRoot): VisibleTreeNodeFromDOM[] {
const tree: VisibleTreeNodeFromDOM[] = [];
function buildTreeNode(node: HTMLLIElement): VisibleTreeNodeFromDOM {
const item: VisibleTreeNodeFromDOM = {
nodeElement: node,
};
if (node.getAttribute('aria-expanded') && node.getAttribute('aria-expanded') === 'true') {
item.children = [];
const childNodes = node.nextElementSibling?.querySelectorAll<HTMLLIElement>(':scope > li') ?? [];
for (const child of childNodes) {
item.children.push(buildTreeNode(child));
}
}
return item;
}
const rootNodes = shadowRoot.querySelectorAll<HTMLLIElement>('ol[role="tree"]>li');
for (const root of rootNodes) {
tree.push(buildTreeNode(root));
}
return tree;
}
/**
* Wait until a certain number of children are rendered. We need this as the
* component uses Lit's until directive, which is async and not within the
* render coordinator's control.
*/
async function waitForRenderedTreeNodeCount(shadowRoot: ShadowRoot, expectedNodeCount: number): Promise<void> {
const actualNodeCount =
shadowRoot.querySelectorAll('ol[role="tree"] > li[role="treeitem"], ol.expanded > li[role="treeitem"]').length;
if (actualNodeCount === expectedNodeCount) {
return;
}
await new Promise<void>(resolve => {
requestAnimationFrame(async () => {
await waitForRenderedTreeNodeCount(shadowRoot, expectedNodeCount);
resolve();
});
});
}
describe('OriginTrialTreeView', () => {
setupLocaleHooks();
it('renders trial names as root tree nodes', async () => {
const {shadowRoot} = await renderOriginTrialTreeViewTreeOutline({
trials: [
trialWithMultipleTokens,
trialWithSingleToken,
trialWithUnparsableToken,
],
});
const visibleItems = shadowRoot.querySelectorAll<HTMLLIElement>(
'ol[role="tree"] > li[role="treeitem"], ol.expanded > li[role="treeitem"]');
assert.lengthOf(visibleItems, 3);
assert.include(visibleItems[0].deepInnerText(), trialWithMultipleTokens.trialName);
assert.include(visibleItems[1].deepInnerText(), trialWithSingleToken.trialName);
assert.include(visibleItems[2].deepInnerText(), trialWithUnparsableToken.trialName);
});
it('renders token with status when there are more than 1 tokens', async () => {
const {component, shadowRoot} = await renderOriginTrialTreeViewTreeOutline({
trials: [
trialWithMultipleTokens, // Node counts by level: 1/3/6/3
],
});
await component.expandRecursively(/* maxDepth= */ 2);
await waitForRenderedTreeNodeCount(shadowRoot, 4);
const visibleTree = visibleNodesToTree(shadowRoot);
// When there are more than 1 tokens in a trial, second level nodes
// should show token status.
const tokenWithStatusNodes = visibleTree[0].children;
assert.exists(tokenWithStatusNodes);
if (tokenWithStatusNodes === undefined) {
return;
}
assert.lengthOf(tokenWithStatusNodes, 3);
for (let i = 0; i < tokenWithStatusNodes.length; i++) {
assert.include(
extractBadgeTextFromTreeNode(tokenWithStatusNodes[i].nodeElement),
trialWithMultipleTokens.tokensWithStatus[i].status,
);
}
});
it('skips token with status when there is only 1 token', async () => {
const {component, shadowRoot} = await renderOriginTrialTreeViewTreeOutline({
trials: [
trialWithSingleToken, // Node counts by level: 1/2/1
],
});
await component.expandRecursively(/* maxDepth= */ 2);
await waitForRenderedTreeNodeCount(shadowRoot, 3);
const visibleTree = visibleNodesToTree(shadowRoot);
// When there is only 1 token, token with status level should be skipped.
const tokenDetailNodes = visibleTree[0].children;
assert.exists(tokenDetailNodes);
if (tokenDetailNodes === undefined) {
return;
}
assert.lengthOf(tokenDetailNodes, 2);
});
it('renders token fields', async () => {
const {component, shadowRoot} = await renderOriginTrialTreeViewTreeOutline({
trials: [
trialWithSingleToken, // Node counts by level: 1/2/1
],
});
await component.expandRecursively(/* maxDepth= */ 2);
await waitForRenderedTreeNodeCount(shadowRoot, 3);
const visibleTree = visibleNodesToTree(shadowRoot);
const tokenDetailNodes = visibleTree[0].children;
assert.exists(tokenDetailNodes);
if (tokenDetailNodes === undefined) {
return;
}
assert.lengthOf(tokenDetailNodes, 2);
const tokenFieldsNode = tokenDetailNodes[0];
const rowsComponent = tokenFieldsNode.nodeElement.querySelector('devtools-widget');
const {innerHTML} = rowsComponent!.shadowRoot!;
const parsedToken = trialWithSingleToken.tokensWithStatus[0].parsedToken;
assert.exists(parsedToken);
if (parsedToken === undefined) {
return;
}
// Note: only origin and usageRestriction field are tested, as other fields
// are not directly rendered:
// - expiryTime: rendered as time format
// - isThirdParty, MatchesSubDomain: boolean flags
assert.include(innerHTML, parsedToken.origin);
assert.include(innerHTML, parsedToken.usageRestriction);
});
it('renders raw token text', async () => {
const {component, shadowRoot} = await renderOriginTrialTreeViewTreeOutline({
trials: [
trialWithSingleToken, // Node counts by level: 1/2/1
],
});
await component.expandRecursively(/* maxDepth= */ 3);
await waitForRenderedTreeNodeCount(shadowRoot, 4);
const visibleTree = visibleNodesToTree(shadowRoot);
const tokenDetailNodes = visibleTree[0].children;
assert.exists(tokenDetailNodes);
if (tokenDetailNodes === undefined) {
return;
}
assert.lengthOf(tokenDetailNodes, 2);
const rawTokenNode = tokenDetailNodes[1];
assert.exists(rawTokenNode.children);
if (rawTokenNode.children === undefined) {
return;
}
assert.lengthOf(rawTokenNode.children, 1);
const innerText = rawTokenNode.children[0].nodeElement.deepInnerText();
assert.include(innerText, trialWithSingleToken.tokensWithStatus[0].rawTokenText);
});
it('shows token count when there are more than 1 tokens in a trial', async () => {
const {shadowRoot} = await renderOriginTrialTreeViewTreeOutline({
trials: [
trialWithMultipleTokens,
],
});
await waitForRenderedTreeNodeCount(shadowRoot, 1);
const visibleTree = visibleNodesToTree(shadowRoot);
const trialNameNode = visibleTree[0];
const badges = extractBadgeTextFromTreeNode(trialNameNode.nodeElement);
assert.lengthOf(badges, 2);
assert.include(badges, `${trialWithMultipleTokens.tokensWithStatus.length} tokens`);
});
it('shows trial status', async () => {
const {shadowRoot} = await renderOriginTrialTreeViewTreeOutline({
trials: [
trialWithMultipleTokens,
],
});
await waitForRenderedTreeNodeCount(shadowRoot, 1);
const visibleTree = visibleNodesToTree(shadowRoot);
const trialNameNode = visibleTree[0];
const badges = extractBadgeTextFromTreeNode(trialNameNode.nodeElement);
assert.lengthOf(badges, 2);
assert.include(badges, trialWithMultipleTokens.status);
});
it('shows token status, when token with status node not expanded', async () => {
const {component, shadowRoot} = await renderOriginTrialTreeViewTreeOutline({
trials: [
trialWithMultipleTokens, // Node counts by level: 1/3/6/3
],
});
await component.expandRecursively(/* maxDepth= */ 2);
await waitForRenderedTreeNodeCount(shadowRoot, 4);
const visibleTree = visibleNodesToTree(shadowRoot);
const trialNameNode = visibleTree[0];
assert.exists(trialNameNode.children);
if (trialNameNode.children === undefined) {
return;
}
assert.lengthOf(trialNameNode.children, 3);
for (let i = 0; i < trialNameNode.children.length; i++) {
const tokenWithStatusNode = trialNameNode.children[i];
assert.isUndefined(tokenWithStatusNode.children);
const badges = extractBadgeTextFromTreeNode(tokenWithStatusNode.nodeElement);
assert.lengthOf(badges, 1);
assert.strictEqual(badges[0], trialWithMultipleTokens.tokensWithStatus[i].status);
}
});
it('hide token status, when token with status node is expanded', async () => {
const {component, shadowRoot} = await renderOriginTrialTreeViewTreeOutline({
trials: [
trialWithMultipleTokens, // Node counts by level: 1/3/6/3
],
});
await component.expandRecursively(/* maxDepth= */ 3);
await waitForRenderedTreeNodeCount(shadowRoot, 10);
const visibleTree = visibleNodesToTree(shadowRoot);
const trialNameNode = visibleTree[0];
assert.exists(trialNameNode.children);
for (const tokenWithStatusNode of trialNameNode.children) {
assert.exists(tokenWithStatusNode.children);
const badges = extractBadgeTextFromTreeNode(tokenWithStatusNode.nodeElement);
assert.lengthOf(badges, 0);
}
});
it('shows trial name for token with status UnknownTrial', async () => {
const unknownTrialName = 'UnkownTrialName';
const {component, shadowRoot} = await renderOriginTrialTreeViewTreeOutline({
trials: [
{
trialName: 'UNKNOWN',
status: Protocol.Page.OriginTrialStatus.ValidTokenNotProvided,
tokensWithStatus: [
{
status: Protocol.Page.OriginTrialTokenStatus.UnknownTrial,
parsedToken: {
trialName: unknownTrialName,
origin: 'https://foo.com',
expiryTime: 1000,
usageRestriction: Protocol.Page.OriginTrialUsageRestriction.None,
isThirdParty: false,
matchSubDomains: false,
},
rawTokenText: tokenPlaceHolder,
},
],
},
],
}); // Node counts by level: 1/2/1
await component.expandRecursively(/* maxDepth= */ 2);
await waitForRenderedTreeNodeCount(shadowRoot, 3);
const visibleTree = visibleNodesToTree(shadowRoot);
const tokenDetailNodes = visibleTree[0].children;
assert.exists(tokenDetailNodes);
if (tokenDetailNodes === undefined) {
return;
}
assert.lengthOf(tokenDetailNodes, 2);
const tokenFieldsNode = tokenDetailNodes[0];
const rowsComponent = tokenFieldsNode.nodeElement.querySelector('devtools-widget');
const {innerHTML} = rowsComponent!.shadowRoot!;
assert.include(innerHTML, unknownTrialName);
});
});