diff --git a/e2e/tests/404.spec.ts b/e2e/tests/404.spec.ts index f52d2d965..f0521c16b 100644 --- a/e2e/tests/404.spec.ts +++ b/e2e/tests/404.spec.ts @@ -15,13 +15,14 @@ */ import {test, expect} from '@playwright/test'; +import {BASE_URL, expect404PageButtons, goTo404Page} from './utils'; test('Bad URL redirection to 404 page', async ({page}) => { const badUrls = [ // Test for bad public asset - 'http://localhost:5555/public/junk', + `${BASE_URL}/public/junk`, // Test for bad URL goes to the not found component - 'http://localhost:5555/bad_url', + `${BASE_URL}/bad_url`, // TODO. Test for bad app urls (e.g. bad feature id) ]; @@ -35,12 +36,85 @@ test('Bad URL redirection to 404 page', async ({page}) => { // Assert that the response status code is 404 expect(response.status()).toBe(404); + + // Check page content + const errorMessage = page.locator('#error-detailed-message'); + await expect(errorMessage).toBeVisible(); + await expect(errorMessage).toContainText( + "We couldn't find the page you're looking for.", + ); + + // Check buttons + await expect(page.locator('#error-action-home-btn')).toBeVisible(); + await expect(page.locator('#error-action-report')).toBeVisible(); }); } }); -test('matches the screenshot', async ({page}) => { - await page.goto('http://localhost:5555/bad_url'); +test('shows similar features and all buttons when results exist', async ({ + page, +}) => { + const query = 'g'; + await goTo404Page(page, query); + + await expect(page.locator('.similar-features-container')).toBeVisible(); + await expect404PageButtons(page, {hasSearch: true}); + + const similarContainerButton = page.locator('#error-action-search-btn'); + const pageContainer = page.locator('.page-container'); + + // Snapshot + await expect(pageContainer).toHaveScreenshot( + 'not-found-error-page-similar-results.png', + ); + + // Clicking the search button should redirect to homepage with search + await Promise.all([page.waitForNavigation(), similarContainerButton.click()]); + await expect(page).toHaveURL(`${BASE_URL}?q=${query}`); +}); + +test('shows only home and report buttons when no similar features found', async ({ + page, +}) => { + const query = 'nonexistent-feature'; + await goTo404Page(page, query); + + await expect(page.locator('.similar-features-container')).toHaveCount(0); + await expect404PageButtons(page, {hasSearch: false}); + + await expect(page.locator('#error-detailed-message')).toContainText( + `We could not find Feature ID: ${query}`, + ); + + await expect(page.locator('.error-message')).toContainText( + 'No similar features found.', + ); +}); + +test('should allow navigation from 404 page', async ({page}) => { + const badUrl = `${BASE_URL}/feature/doesNotExist123`; + await page.goto(badUrl); + await expect(page).toHaveURL(badUrl); + + // Home button navigation + const homeButton = page.locator('#error-action-home-btn'); + await expect(homeButton).toBeVisible(); + await homeButton.click(); + await expect(page).toHaveURL(BASE_URL); + + await page.goBack(); + + // Report an issue button should be present + const reportButton = page.locator('#error-action-report'); + await expect(reportButton).toBeVisible(); + await expect(reportButton).toHaveAttribute( + 'href', + 'https://github.com/GoogleChrome/webstatus.dev/issues/new/choose', + ); +}); + +test('matches the screenshot 404 not found page', async ({page}) => { + await page.goto(`${BASE_URL}/bad_url`); const pageContainer = page.locator('.page-container'); await expect(pageContainer).toHaveScreenshot('not-found-error-page.png'); }); diff --git a/e2e/tests/404.spec.ts-snapshots/not-found-error-page-chromium-linux.png b/e2e/tests/404.spec.ts-snapshots/not-found-error-page-chromium-linux.png index f6b9c8481..b49dce130 100644 Binary files a/e2e/tests/404.spec.ts-snapshots/not-found-error-page-chromium-linux.png and b/e2e/tests/404.spec.ts-snapshots/not-found-error-page-chromium-linux.png differ diff --git a/e2e/tests/404.spec.ts-snapshots/not-found-error-page-firefox-linux.png b/e2e/tests/404.spec.ts-snapshots/not-found-error-page-firefox-linux.png index 8bd8448b8..68ffb2b98 100644 Binary files a/e2e/tests/404.spec.ts-snapshots/not-found-error-page-firefox-linux.png and b/e2e/tests/404.spec.ts-snapshots/not-found-error-page-firefox-linux.png differ diff --git a/e2e/tests/404.spec.ts-snapshots/not-found-error-page-similar-results-chromium-linux.png b/e2e/tests/404.spec.ts-snapshots/not-found-error-page-similar-results-chromium-linux.png new file mode 100644 index 000000000..82986d96c Binary files /dev/null and b/e2e/tests/404.spec.ts-snapshots/not-found-error-page-similar-results-chromium-linux.png differ diff --git a/e2e/tests/404.spec.ts-snapshots/not-found-error-page-similar-results-firefox-linux.png b/e2e/tests/404.spec.ts-snapshots/not-found-error-page-similar-results-firefox-linux.png new file mode 100644 index 000000000..8e544bb4c Binary files /dev/null and b/e2e/tests/404.spec.ts-snapshots/not-found-error-page-similar-results-firefox-linux.png differ diff --git a/e2e/tests/404.spec.ts-snapshots/not-found-error-page-similar-results-webkit-linux.png b/e2e/tests/404.spec.ts-snapshots/not-found-error-page-similar-results-webkit-linux.png new file mode 100644 index 000000000..e8ff5bdf5 Binary files /dev/null and b/e2e/tests/404.spec.ts-snapshots/not-found-error-page-similar-results-webkit-linux.png differ diff --git a/e2e/tests/404.spec.ts-snapshots/not-found-error-page-webkit-linux.png b/e2e/tests/404.spec.ts-snapshots/not-found-error-page-webkit-linux.png index 73a80f5b0..7cff3ffa6 100644 Binary files a/e2e/tests/404.spec.ts-snapshots/not-found-error-page-webkit-linux.png and b/e2e/tests/404.spec.ts-snapshots/not-found-error-page-webkit-linux.png differ diff --git a/e2e/tests/utils.ts b/e2e/tests/utils.ts index 64f92f4dd..4a8e4b307 100644 --- a/e2e/tests/utils.ts +++ b/e2e/tests/utils.ts @@ -18,6 +18,8 @@ import {Page, expect} from '@playwright/test'; const DEFAULT_FAKE_NOW = 'Dec 1 2020 12:34:56'; +export const BASE_URL = 'http://localhost:5555'; + export async function setupFakeNow( page: Page, fakeNowDateString = DEFAULT_FAKE_NOW, @@ -86,3 +88,27 @@ export async function loginAsUser(page: Page, username: string) { await popup.getByText(username).click(); await popup.waitForEvent('close'); } + +export async function goTo404Page(page, query: string): Promise { + await page.goto(`${BASE_URL}/features/${query}`); + await expect(page).toHaveURL( + `${BASE_URL}/errors-404/feature-not-found?q=${query}`, + ); + + const response = await page.context().request.fetch(page.url()); + expect(response.status()).toBe(404); +} + +export async function expect404PageButtons( + page, + {hasSearch}: {hasSearch: boolean}, +) { + await expect(page.locator('#error-action-home-btn')).toBeVisible(); + await expect(page.locator('#error-action-report')).toBeVisible(); + + if (hasSearch) { + await expect(page.locator('#error-action-search-btn')).toBeVisible(); + } else { + await expect(page.locator('#error-action-search-btn')).toHaveCount(0); + } +} diff --git a/frontend/src/static/js/components/test/webstatus-notfound-error-page.test.ts b/frontend/src/static/js/components/test/webstatus-notfound-error-page.test.ts new file mode 100644 index 000000000..144f1d80e --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-notfound-error-page.test.ts @@ -0,0 +1,182 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, fixture, html} from '@open-wc/testing'; +import '../webstatus-notfound-error-page.js'; +import {WebstatusNotFoundErrorPage} from '../webstatus-notfound-error-page.js'; +import {Task} from '@lit/task'; +import {APIClient} from '../../contexts/api-client-context.js'; +import {GITHUB_REPO_ISSUE_LINK} from '../../utils/constants.js'; + +type SimilarFeature = {name: string; url: string}; + +describe('webstatus-notfound-error-page', () => { + const featureIdWithMockResults = 'g'; + const mockSimilarFeatures: SimilarFeature[] = [ + {name: 'Feature One', url: '/features/dignissimos44'}, + {name: 'Feature Two', url: '/features/fugiat37'}, + ]; + + it('renders the correct error message when featureId is missing', async () => { + const component = await fixture( + html``, + ); + + expect( + component.shadowRoot + ?.querySelector('#error-status-code') + ?.textContent?.trim(), + ).to.equal('404'); + + expect( + component.shadowRoot + ?.querySelector('#error-headline') + ?.textContent?.trim(), + ).to.equal('Page not found'); + + expect( + component.shadowRoot + ?.querySelector('#error-detailed-message .error-message') + ?.textContent?.trim(), + ).to.equal("We couldn't find the page you're looking for."); + }); + + it('renders correct message when featureId is provided', async () => { + const component = await fixture(html` + + `); + + expect( + component.shadowRoot?.querySelector('#error-detailed-message') + ?.textContent, + ).to.include('We could not find Feature ID: test-feature'); + }); + + it('displays "Loading similar features..." when the API request is pending', async () => { + const component = await createComponentWithMockedSimilarFeatures( + 'test-feature', + [], + {stayPending: true}, + ); + + const loadingMessage = + component.shadowRoot?.querySelector('.loading-message'); + expect(loadingMessage).to.exist; + expect(loadingMessage?.textContent?.trim()).to.equal( + 'Loading similar features...', + ); + }); + + it('renders similar features when API returns results', async () => { + const component = await createComponentWithMockedSimilarFeatures( + featureIdWithMockResults, + mockSimilarFeatures, + ); + + const featureList = + component.shadowRoot?.querySelectorAll('.feature-list li'); + expect(featureList?.length).to.equal(2); + expect(featureList?.[0]?.textContent?.trim()).to.equal('Feature One'); + expect(featureList?.[1]?.textContent?.trim()).to.equal('Feature Two'); + }); + + it('renders only two buttons when featureId does not exist', async () => { + const component = await fixture(html` + + `); + + expect(component.shadowRoot?.querySelector('#error-action-search-btn')).to + .not.exist; + expect(component.shadowRoot?.querySelector('#error-action-home-btn')).to + .exist; + expect(component.shadowRoot?.querySelector('#error-action-report')).to + .exist; + }); + + it('renders all three buttons when featureId and similar results exist', async () => { + const component = await createComponentWithMockedSimilarFeatures( + featureIdWithMockResults, + mockSimilarFeatures, + ); + + expect(component.shadowRoot?.querySelector('#error-action-search-btn')).to + .exist; + expect(component.shadowRoot?.querySelector('#error-action-home-btn')).to + .exist; + expect(component.shadowRoot?.querySelector('#error-action-report')).to + .exist; + }); + + it('search button contains the correct query parameter when similar results exist', async () => { + const component = await createComponentWithMockedSimilarFeatures( + featureIdWithMockResults, + mockSimilarFeatures, + ); + + const searchButton = component.shadowRoot?.querySelector( + '#error-action-search-btn', + ); + expect(searchButton?.getAttribute('href')).to.equal( + `/?q=${featureIdWithMockResults}`, + ); + }); + + it('report issue button links to GitHub', async () => { + const component = await fixture(html` + + `); + + const reportButton = component.shadowRoot?.querySelector( + '#error-action-report', + ); + expect(reportButton?.getAttribute('href')).to.equal(GITHUB_REPO_ISSUE_LINK); + }); + + async function createComponentWithMockedSimilarFeatures( + featureId: string, + mockData: SimilarFeature[], + options: {stayPending?: boolean} = {}, + ): Promise { + const component = await fixture(html` + + `); + + component._similarResults = new Task<[APIClient, string], SimilarFeature[]>( + component, + { + args: () => [undefined as unknown as APIClient, featureId], + task: async () => { + if (options.stayPending) return new Promise(() => {}); + return mockData; + }, + }, + ); + + component._similarResults.run(); + await component.updateComplete; + return component; + } +}); diff --git a/frontend/src/static/js/components/webstatus-feature-page.ts b/frontend/src/static/js/components/webstatus-feature-page.ts index 1aee4b4b2..bc656d591 100644 --- a/frontend/src/static/js/components/webstatus-feature-page.ts +++ b/frontend/src/static/js/components/webstatus-feature-page.ts @@ -43,12 +43,12 @@ import { } from './webstatus-overview-cells.js'; import './webstatus-gchart'; -import {NotFoundError} from '../api/errors.js'; import {BaseChartsPage} from './webstatus-base-charts-page.js'; import './webstatus-feature-wpt-progress-chart-panel.js'; import './webstatus-feature-usage-chart-panel.js'; import {DataFetchedEvent} from './webstatus-line-chart-panel.js'; +import {NotFoundError} from '../api/errors.js'; // CanIUseData is a slimmed down interface of the data returned from the API. interface CanIUseData { items?: { @@ -219,6 +219,19 @@ export class FeaturePage extends BaseChartsPage { } return Promise.reject('api client and/or featureId not set'); }, + onError: async error => { + if (error instanceof NotFoundError) { + const queryParam = this.featureId ? `?q=${this.featureId}` : ''; + + // TODO: cannot use navigateToUrl because it creates a + // circular dependency. + // For now use the window href and revisit when navigateToUrl + // is move to another location. + window.location.href = `/errors-404/feature-not-found${queryParam}`; + } else { + console.error('Unexpected error in _loadingTask:', error); + } + }, }); this._loadingMetadataTask = new Task(this, { @@ -242,16 +255,7 @@ export class FeaturePage extends BaseChartsPage { return html` ${this._loadingTask?.render({ complete: () => this.renderWhenComplete(), - error: error => { - if (error instanceof NotFoundError) { - // TODO: cannot use navigateToUrl because it creates a - // circular dependency. - // For now use the window href and revisit when navigateToUrl - // is move to another location. - window.location.href = '/errors-404/feature-not-found'; - } - return this.renderWhenError(); - }, + error: () => this.renderWhenError(), initial: () => this.renderWhenInitial(), pending: () => this.renderWhenPending(), })} diff --git a/frontend/src/static/js/components/webstatus-notfound-error-page.ts b/frontend/src/static/js/components/webstatus-notfound-error-page.ts index 72869ccc9..2446da02a 100644 --- a/frontend/src/static/js/components/webstatus-notfound-error-page.ts +++ b/frontend/src/static/js/components/webstatus-notfound-error-page.ts @@ -15,12 +15,65 @@ */ import {LitElement, html, type TemplateResult, CSSResultGroup, css} from 'lit'; -import {customElement} from 'lit/decorators.js'; +import {customElement, property, state} from 'lit/decorators.js'; import {SHARED_STYLES} from '../css/shared-css.js'; import {GITHUB_REPO_ISSUE_LINK} from '../utils/constants.js'; +import {getSearchQuery, formatFeaturePageUrl} from '../utils/urls.js'; +import {consume} from '@lit/context'; +import {APIClient, apiClientContext} from '../contexts/api-client-context.js'; +import {Task} from '@lit/task'; +import {FeatureSortOrderType} from '../api/client.js'; +import {Toast} from '../utils/toast.js'; -@customElement('webstatus-not-found-error-page') +type SimilarFeature = {name: string; url: string}; + +@customElement('webstatus-notfound-error-page') export class WebstatusNotFoundErrorPage extends LitElement { + _similarResults?: Task<[APIClient, string], SimilarFeature[]>; + + @property({type: Object}) + location!: {search: string}; // Set by router. + + @consume({context: apiClientContext}) + @state() + apiClient!: APIClient; + + constructor() { + super(); + this._similarResults = new Task<[APIClient, string], SimilarFeature[]>( + this, + { + args: () => [this.apiClient, getSearchQuery(this.location)], + task: async ([apiClient, featureId]) => { + if (!featureId) return []; + try { + const response = await apiClient.getFeatures( + featureId, + '' as FeatureSortOrderType, + undefined, + 0, + 5, + ); + const data = response.data; + return Array.isArray(data) + ? data.map(f => ({ + name: f.name, + url: formatFeaturePageUrl(f), + })) + : []; + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'An unknown error occurred'; + await new Toast().toast(message, 'danger', 'exclamation-triangle'); + return []; + } + }, + }, + ); + } + static get styles(): CSSResultGroup { return [ SHARED_STYLES, @@ -31,8 +84,8 @@ export class WebstatusNotFoundErrorPage extends LitElement { flex-direction: column; justify-content: center; align-items: center; - gap: 48px; display: inline-flex; + gap: 32px; } #error-header { align-self: stretch; @@ -50,6 +103,12 @@ export class WebstatusNotFoundErrorPage extends LitElement { line-height: 22.5px; word-wrap: break-word; } + #error-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--content-padding); + } #error-headline { color: #1d2430; font-size: 32px; @@ -57,69 +116,134 @@ export class WebstatusNotFoundErrorPage extends LitElement { word-wrap: break-word; } #error-detailed-message { - color: #6c7381; font-size: 15px; font-weight: 400; line-height: 22.5px; word-wrap: break-word; } - #error-actions { - justify-content: center; - align-items: center; - gap: 24px; - display: inline-flex; + + .error-message { + color: #6c7381; } - #error-action-home { - width: 136px; - padding-left: 16px; - padding-right: 16px; - justify-content: center; - align-items: center; - gap: 8px; - display: flex; + .similar-features-container { + text-align: left; + padding: 12px; + max-width: 400px; } - #error-action-report { - width: 145px; - padding-left: 16px; - padding-right: 16px; - border-radius: 4px; - justify-content: center; - align-items: center; - gap: 8px; - display: flex; + .similar-results-header { + color: #1a1a1a; + font-weight: 500; + margin-bottom: 6px; } - - #error-action-report a { - color: inherit; + .feature-list { + list-style: none; + padding: 0; + margin: 0; + } + .feature-list li { + padding: 6px 0; + } + .feature-list li a { text-decoration: none; + color: #007bff; + font-weight: 500; + } + .feature-list li a:hover { + text-decoration: underline; + color: #0056b3; } `, ]; } - protected render(): TemplateResult { + + private _renderErrorHeader(featureId: string | undefined): TemplateResult { return html` -
-
-
404
-
Page not found
-
- We couldn't find the page you're looking for. -
+
+
404
+
Page not found
+
+ ${featureId + ? html`We could not find Feature ID: ${featureId}` + : html`We couldn't find the page you're looking for.`}
+
+ `; + } -
-
- Go back home -
- -
+ private _renderSimilarFeatures( + features: SimilarFeature[] | undefined, + ): TemplateResult { + if (!features?.length) { + return html`

No similar features found.

`; + } + return html` +
+

Here are some similar features:

+ +
+ `; + } + + private _renderActionButtons( + showSearchMore: boolean = false, + featureId?: string, + ): TemplateResult { + return html` +
+ ${showSearchMore && featureId + ? html` + + Search for more similar features + + ` + : ''} + + Go back home + + + + Report an issue + +
+ `; + } + + protected render(): TemplateResult { + const featureId = getSearchQuery(this.location); + + return html` +
+ ${this._renderErrorHeader(featureId)} + ${featureId + ? this._similarResults?.render({ + initial: () => + html`

Preparing search...

`, + pending: () => + html`

+ Loading similar features... +

`, + complete: features => + html` ${this._renderSimilarFeatures(features)} + ${this._renderActionButtons(features?.length > 0, featureId)}`, + error: error => + html`

+ Oops, something went wrong: ${error} +

`, + }) + : this._renderActionButtons(false)}
`; } diff --git a/frontend/src/static/js/utils/app-router.ts b/frontend/src/static/js/utils/app-router.ts index 5012a7993..85d34b701 100644 --- a/frontend/src/static/js/utils/app-router.ts +++ b/frontend/src/static/js/utils/app-router.ts @@ -38,7 +38,7 @@ export const initRouter = async (element: HTMLElement): Promise => { }, { path: '(.*)', - component: 'webstatus-not-found-error-page', + component: 'webstatus-notfound-error-page', }, ]); return router;