Skip to content

Check and redirect to search query from 404 page, if results exist #1291

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
82 changes: 78 additions & 4 deletions e2e/tests/404.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
];

Expand All @@ -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');
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions e2e/tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
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);
}
}
Original file line number Diff line number Diff line change
@@ -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<WebstatusNotFoundErrorPage>(
html`<webstatus-notfound-error-page
.location=${{search: ''}}
></webstatus-notfound-error-page>`,
);

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<WebstatusNotFoundErrorPage>(html`
<webstatus-notfound-error-page
.location=${{search: '?q=test-feature'}}
></webstatus-notfound-error-page>
`);

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<WebstatusNotFoundErrorPage>(html`
<webstatus-notfound-error-page
.location=${{search: ''}}
></webstatus-notfound-error-page>
`);

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<WebstatusNotFoundErrorPage>(html`
<webstatus-notfound-error-page
.location=${{search: ''}}
></webstatus-notfound-error-page>
`);

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<WebstatusNotFoundErrorPage> {
const component = await fixture<WebstatusNotFoundErrorPage>(html`
<webstatus-notfound-error-page
.location=${{search: `?q=${featureId}`}}
></webstatus-notfound-error-page>
`);

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;
}
});
26 changes: 15 additions & 11 deletions frontend/src/static/js/components/webstatus-feature-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down Expand Up @@ -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, {
Expand All @@ -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(),
})}
Expand Down
Loading