Skip to content

Commit c84f09a

Browse files
authored
Check and redirect to search query from 404 page, if results exist (#1291)
* Check and redirect to search query from 404 page, if results exist * Add tests and 404 html page * Combine tests and implement lit tasks for similar features fetch * Move e2e 404 test utility functions to specific file * Refactor redeability webstatus not found error page * Linting fix 404 page playwright tests * Use mock API in unit tests, typo correction 404 page
1 parent 11b47c3 commit c84f09a

12 files changed

+476
-66
lines changed

e2e/tests/404.spec.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
1515
*/
1616

1717
import {test, expect} from '@playwright/test';
18+
import {BASE_URL, expect404PageButtons, goTo404Page} from './utils';
1819

1920
test('Bad URL redirection to 404 page', async ({page}) => {
2021
const badUrls = [
2122
// Test for bad public asset
22-
'http://localhost:5555/public/junk',
23+
`${BASE_URL}/public/junk`,
2324
// Test for bad URL goes to the not found component
24-
'http://localhost:5555/bad_url',
25+
`${BASE_URL}/bad_url`,
2526
// TODO. Test for bad app urls (e.g. bad feature id)
2627
];
2728

@@ -35,12 +36,85 @@ test('Bad URL redirection to 404 page', async ({page}) => {
3536

3637
// Assert that the response status code is 404
3738
expect(response.status()).toBe(404);
39+
40+
// Check page content
41+
const errorMessage = page.locator('#error-detailed-message');
42+
await expect(errorMessage).toBeVisible();
43+
await expect(errorMessage).toContainText(
44+
"We couldn't find the page you're looking for.",
45+
);
46+
47+
// Check buttons
48+
await expect(page.locator('#error-action-home-btn')).toBeVisible();
49+
await expect(page.locator('#error-action-report')).toBeVisible();
3850
});
3951
}
4052
});
4153

42-
test('matches the screenshot', async ({page}) => {
43-
await page.goto('http://localhost:5555/bad_url');
54+
test('shows similar features and all buttons when results exist', async ({
55+
page,
56+
}) => {
57+
const query = 'g';
58+
await goTo404Page(page, query);
59+
60+
await expect(page.locator('.similar-features-container')).toBeVisible();
61+
await expect404PageButtons(page, {hasSearch: true});
62+
63+
const similarContainerButton = page.locator('#error-action-search-btn');
64+
const pageContainer = page.locator('.page-container');
65+
66+
// Snapshot
67+
await expect(pageContainer).toHaveScreenshot(
68+
'not-found-error-page-similar-results.png',
69+
);
70+
71+
// Clicking the search button should redirect to homepage with search
72+
await Promise.all([page.waitForNavigation(), similarContainerButton.click()]);
73+
await expect(page).toHaveURL(`${BASE_URL}?q=${query}`);
74+
});
75+
76+
test('shows only home and report buttons when no similar features found', async ({
77+
page,
78+
}) => {
79+
const query = 'nonexistent-feature';
80+
await goTo404Page(page, query);
81+
82+
await expect(page.locator('.similar-features-container')).toHaveCount(0);
83+
await expect404PageButtons(page, {hasSearch: false});
84+
85+
await expect(page.locator('#error-detailed-message')).toContainText(
86+
`We could not find Feature ID: ${query}`,
87+
);
88+
89+
await expect(page.locator('.error-message')).toContainText(
90+
'No similar features found.',
91+
);
92+
});
93+
94+
test('should allow navigation from 404 page', async ({page}) => {
95+
const badUrl = `${BASE_URL}/feature/doesNotExist123`;
96+
await page.goto(badUrl);
97+
await expect(page).toHaveURL(badUrl);
98+
99+
// Home button navigation
100+
const homeButton = page.locator('#error-action-home-btn');
101+
await expect(homeButton).toBeVisible();
102+
await homeButton.click();
103+
await expect(page).toHaveURL(BASE_URL);
104+
105+
await page.goBack();
106+
107+
// Report an issue button should be present
108+
const reportButton = page.locator('#error-action-report');
109+
await expect(reportButton).toBeVisible();
110+
await expect(reportButton).toHaveAttribute(
111+
'href',
112+
'https://github.com/GoogleChrome/webstatus.dev/issues/new/choose',
113+
);
114+
});
115+
116+
test('matches the screenshot 404 not found page', async ({page}) => {
117+
await page.goto(`${BASE_URL}/bad_url`);
44118
const pageContainer = page.locator('.page-container');
45119
await expect(pageContainer).toHaveScreenshot('not-found-error-page.png');
46120
});
-289 Bytes
Loading
-215 Bytes
Loading
24.8 KB
Loading
35.2 KB
Loading
27.2 KB
Loading
49 Bytes
Loading

e2e/tests/utils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {Page, expect} from '@playwright/test';
1818

1919
const DEFAULT_FAKE_NOW = 'Dec 1 2020 12:34:56';
2020

21+
export const BASE_URL = 'http://localhost:5555';
22+
2123
export async function setupFakeNow(
2224
page: Page,
2325
fakeNowDateString = DEFAULT_FAKE_NOW,
@@ -86,3 +88,27 @@ export async function loginAsUser(page: Page, username: string) {
8688
await popup.getByText(username).click();
8789
await popup.waitForEvent('close');
8890
}
91+
92+
export async function goTo404Page(page, query: string): Promise<void> {
93+
await page.goto(`${BASE_URL}/features/${query}`);
94+
await expect(page).toHaveURL(
95+
`${BASE_URL}/errors-404/feature-not-found?q=${query}`,
96+
);
97+
98+
const response = await page.context().request.fetch(page.url());
99+
expect(response.status()).toBe(404);
100+
}
101+
102+
export async function expect404PageButtons(
103+
page,
104+
{hasSearch}: {hasSearch: boolean},
105+
) {
106+
await expect(page.locator('#error-action-home-btn')).toBeVisible();
107+
await expect(page.locator('#error-action-report')).toBeVisible();
108+
109+
if (hasSearch) {
110+
await expect(page.locator('#error-action-search-btn')).toBeVisible();
111+
} else {
112+
await expect(page.locator('#error-action-search-btn')).toHaveCount(0);
113+
}
114+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {expect, fixture, html} from '@open-wc/testing';
18+
import '../webstatus-notfound-error-page.js';
19+
import {WebstatusNotFoundErrorPage} from '../webstatus-notfound-error-page.js';
20+
import {Task} from '@lit/task';
21+
import {APIClient} from '../../contexts/api-client-context.js';
22+
import {GITHUB_REPO_ISSUE_LINK} from '../../utils/constants.js';
23+
24+
type SimilarFeature = {name: string; url: string};
25+
26+
describe('webstatus-notfound-error-page', () => {
27+
const featureIdWithMockResults = 'g';
28+
const mockSimilarFeatures: SimilarFeature[] = [
29+
{name: 'Feature One', url: '/features/dignissimos44'},
30+
{name: 'Feature Two', url: '/features/fugiat37'},
31+
];
32+
33+
it('renders the correct error message when featureId is missing', async () => {
34+
const component = await fixture<WebstatusNotFoundErrorPage>(
35+
html`<webstatus-notfound-error-page
36+
.location=${{search: ''}}
37+
></webstatus-notfound-error-page>`,
38+
);
39+
40+
expect(
41+
component.shadowRoot
42+
?.querySelector('#error-status-code')
43+
?.textContent?.trim(),
44+
).to.equal('404');
45+
46+
expect(
47+
component.shadowRoot
48+
?.querySelector('#error-headline')
49+
?.textContent?.trim(),
50+
).to.equal('Page not found');
51+
52+
expect(
53+
component.shadowRoot
54+
?.querySelector('#error-detailed-message .error-message')
55+
?.textContent?.trim(),
56+
).to.equal("We couldn't find the page you're looking for.");
57+
});
58+
59+
it('renders correct message when featureId is provided', async () => {
60+
const component = await fixture<WebstatusNotFoundErrorPage>(html`
61+
<webstatus-notfound-error-page
62+
.location=${{search: '?q=test-feature'}}
63+
></webstatus-notfound-error-page>
64+
`);
65+
66+
expect(
67+
component.shadowRoot?.querySelector('#error-detailed-message')
68+
?.textContent,
69+
).to.include('We could not find Feature ID: test-feature');
70+
});
71+
72+
it('displays "Loading similar features..." when the API request is pending', async () => {
73+
const component = await createComponentWithMockedSimilarFeatures(
74+
'test-feature',
75+
[],
76+
{stayPending: true},
77+
);
78+
79+
const loadingMessage =
80+
component.shadowRoot?.querySelector('.loading-message');
81+
expect(loadingMessage).to.exist;
82+
expect(loadingMessage?.textContent?.trim()).to.equal(
83+
'Loading similar features...',
84+
);
85+
});
86+
87+
it('renders similar features when API returns results', async () => {
88+
const component = await createComponentWithMockedSimilarFeatures(
89+
featureIdWithMockResults,
90+
mockSimilarFeatures,
91+
);
92+
93+
const featureList =
94+
component.shadowRoot?.querySelectorAll('.feature-list li');
95+
expect(featureList?.length).to.equal(2);
96+
expect(featureList?.[0]?.textContent?.trim()).to.equal('Feature One');
97+
expect(featureList?.[1]?.textContent?.trim()).to.equal('Feature Two');
98+
});
99+
100+
it('renders only two buttons when featureId does not exist', async () => {
101+
const component = await fixture<WebstatusNotFoundErrorPage>(html`
102+
<webstatus-notfound-error-page
103+
.location=${{search: ''}}
104+
></webstatus-notfound-error-page>
105+
`);
106+
107+
expect(component.shadowRoot?.querySelector('#error-action-search-btn')).to
108+
.not.exist;
109+
expect(component.shadowRoot?.querySelector('#error-action-home-btn')).to
110+
.exist;
111+
expect(component.shadowRoot?.querySelector('#error-action-report')).to
112+
.exist;
113+
});
114+
115+
it('renders all three buttons when featureId and similar results exist', async () => {
116+
const component = await createComponentWithMockedSimilarFeatures(
117+
featureIdWithMockResults,
118+
mockSimilarFeatures,
119+
);
120+
121+
expect(component.shadowRoot?.querySelector('#error-action-search-btn')).to
122+
.exist;
123+
expect(component.shadowRoot?.querySelector('#error-action-home-btn')).to
124+
.exist;
125+
expect(component.shadowRoot?.querySelector('#error-action-report')).to
126+
.exist;
127+
});
128+
129+
it('search button contains the correct query parameter when similar results exist', async () => {
130+
const component = await createComponentWithMockedSimilarFeatures(
131+
featureIdWithMockResults,
132+
mockSimilarFeatures,
133+
);
134+
135+
const searchButton = component.shadowRoot?.querySelector(
136+
'#error-action-search-btn',
137+
);
138+
expect(searchButton?.getAttribute('href')).to.equal(
139+
`/?q=${featureIdWithMockResults}`,
140+
);
141+
});
142+
143+
it('report issue button links to GitHub', async () => {
144+
const component = await fixture<WebstatusNotFoundErrorPage>(html`
145+
<webstatus-notfound-error-page
146+
.location=${{search: ''}}
147+
></webstatus-notfound-error-page>
148+
`);
149+
150+
const reportButton = component.shadowRoot?.querySelector(
151+
'#error-action-report',
152+
);
153+
expect(reportButton?.getAttribute('href')).to.equal(GITHUB_REPO_ISSUE_LINK);
154+
});
155+
156+
async function createComponentWithMockedSimilarFeatures(
157+
featureId: string,
158+
mockData: SimilarFeature[],
159+
options: {stayPending?: boolean} = {},
160+
): Promise<WebstatusNotFoundErrorPage> {
161+
const component = await fixture<WebstatusNotFoundErrorPage>(html`
162+
<webstatus-notfound-error-page
163+
.location=${{search: `?q=${featureId}`}}
164+
></webstatus-notfound-error-page>
165+
`);
166+
167+
component._similarResults = new Task<[APIClient, string], SimilarFeature[]>(
168+
component,
169+
{
170+
args: () => [undefined as unknown as APIClient, featureId],
171+
task: async () => {
172+
if (options.stayPending) return new Promise(() => {});
173+
return mockData;
174+
},
175+
},
176+
);
177+
178+
component._similarResults.run();
179+
await component.updateComplete;
180+
return component;
181+
}
182+
});

frontend/src/static/js/components/webstatus-feature-page.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,12 @@ import {
4343
} from './webstatus-overview-cells.js';
4444

4545
import './webstatus-gchart';
46-
import {NotFoundError} from '../api/errors.js';
4746
import {BaseChartsPage} from './webstatus-base-charts-page.js';
4847

4948
import './webstatus-feature-wpt-progress-chart-panel.js';
5049
import './webstatus-feature-usage-chart-panel.js';
5150
import {DataFetchedEvent} from './webstatus-line-chart-panel.js';
51+
import {NotFoundError} from '../api/errors.js';
5252
// CanIUseData is a slimmed down interface of the data returned from the API.
5353
interface CanIUseData {
5454
items?: {
@@ -219,6 +219,19 @@ export class FeaturePage extends BaseChartsPage {
219219
}
220220
return Promise.reject('api client and/or featureId not set');
221221
},
222+
onError: async error => {
223+
if (error instanceof NotFoundError) {
224+
const queryParam = this.featureId ? `?q=${this.featureId}` : '';
225+
226+
// TODO: cannot use navigateToUrl because it creates a
227+
// circular dependency.
228+
// For now use the window href and revisit when navigateToUrl
229+
// is move to another location.
230+
window.location.href = `/errors-404/feature-not-found${queryParam}`;
231+
} else {
232+
console.error('Unexpected error in _loadingTask:', error);
233+
}
234+
},
222235
});
223236

224237
this._loadingMetadataTask = new Task(this, {
@@ -242,16 +255,7 @@ export class FeaturePage extends BaseChartsPage {
242255
return html`
243256
${this._loadingTask?.render({
244257
complete: () => this.renderWhenComplete(),
245-
error: error => {
246-
if (error instanceof NotFoundError) {
247-
// TODO: cannot use navigateToUrl because it creates a
248-
// circular dependency.
249-
// For now use the window href and revisit when navigateToUrl
250-
// is move to another location.
251-
window.location.href = '/errors-404/feature-not-found';
252-
}
253-
return this.renderWhenError();
254-
},
258+
error: () => this.renderWhenError(),
255259
initial: () => this.renderWhenInitial(),
256260
pending: () => this.renderWhenPending(),
257261
})}

0 commit comments

Comments
 (0)