Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions e2e/tests/feature-page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,51 @@ test('date range changes are preserved in the URL', async ({page}) => {
const endDateInputElement3 = endDateSelector3.locator('input');
expect(await endDateInputElement3.inputValue()).toBe(endDate);
});

test('redirects for a moved feature', async ({page}) => {
await page.goto('http://localhost:5555/features/old-feature');

// Expect the URL to be updated to the new feature's URL.
await expect(page).toHaveURL(
'http://localhost:5555/features/new-feature?redirected_from=old-feature',
);

// Expect the title and redirect banner to be correct.
await expect(page.locator('h1')).toHaveText('New Feature');
await expect(
page.locator(
'sl-alert:has-text("You have been redirected from an old feature ID")',
),
).toBeVisible();

// Wait for charts to load to avoid flakiness in the screenshot.
await page.waitForSelector('#feature-wpt-implementation-progress-0-complete');

// Take a screenshot for visual verification.
const pageContainer = page.locator('.page-container');
await expect(pageContainer).toHaveScreenshot();
});

test('shows gone page for a split feature', async ({page}) => {
await page.goto('http://localhost:5555/features/before-split-feature');

// Expect to be redirected to the 'feature-gone-split' page.
await expect(page).toHaveURL(
'http://localhost:5555/errors-410/feature-gone-split?new_features=after-split-feature-1,after-split-feature-2',
);

// Assert that the content of the 410 page is correct.
await expect(page.locator('.new-results-header')).toContainText(
'Please see the following new features',
);
await expect(
page.locator('a[href="/features/after-split-feature-1"]'),
).toBeVisible();
await expect(
page.locator('a[href="/features/after-split-feature-2"]'),
).toBeVisible();

// Take a screenshot for visual verification.
const pageContainer = page.locator('.container'); // Assuming a generic container for the error page.
await expect(pageContainer).toHaveScreenshot();
});
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.
4 changes: 4 additions & 0 deletions frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ http {
try_files $uri /index.html;
}

location = /errors-410/feature-gone-split {
try_files $uri /index.html;
}

# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
add_header X-Content-Type-Options "nosniff";
Expand Down
34 changes: 29 additions & 5 deletions frontend/src/static/js/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import createClient, {
ParseAsResponse,
} from 'openapi-fetch';
import {type components, type paths} from 'webstatus.dev-backend';
import {createAPIError} from './errors.js';
import {
createAPIError,
FeatureGoneSplitError,
FeatureMovedError,
} from './errors.js';

import {
MediaType,
Expand Down Expand Up @@ -302,17 +306,37 @@ export class APIClient {
const qsParams: paths['/v1/features/{feature_id}']['get']['parameters']['query'] =
{};
if (wptMetricView) qsParams.wpt_metric_view = wptMetricView;
const {data, error} = await this.client.GET('/v1/features/{feature_id}', {
const resp = await this.client.GET('/v1/features/{feature_id}', {
...temporaryFetchOptions,
params: {
path: {feature_id: featureId},
query: qsParams,
},
});
if (error !== undefined) {
throw createAPIError(error);
if (resp.error !== undefined) {
const data = resp.error;
if (resp.response.status === 410 && 'new_features' in data) {
// Type narrowing doesn't work.
// https://github.com/openapi-ts/openapi-typescript/issues/1723
// We have to force it.
const featureGoneData =
data as components['schemas']['FeatureGoneError'];
throw new FeatureGoneSplitError(
resp.error.message,
featureGoneData.new_features.map(f => f.id),
);
}
throw createAPIError(resp.error);
}
return data;
if (resp.response.redirected) {
const featureId = resp.response.url.split('/').pop() || '';
throw new FeatureMovedError(
'redirected to feature',
featureId,
resp.data,
);
}
return resp.data;
}

public async getFeatureMetadata(
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/static/js/api/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {type components} from 'webstatus.dev-backend';

/**
* Copyright 2024 Google LLC
*
Expand Down Expand Up @@ -106,3 +108,27 @@ export class UnknownError extends ApiError {
this.name = 'UnknownError';
}
}

export class FeatureGoneSplitError extends ApiError {
newFeatureIds: Array<string>;
constructor(message: string, newFeatureIds: Array<string>) {
super(message, 410);
this.name = 'FeatureGoneSplitError';
this.newFeatureIds = newFeatureIds;
}
}

export class FeatureMovedError extends ApiError {
newFeatureId: string;
feature: components['schemas']['Feature'];
constructor(
message: string,
newFeatureId: string,
feature: components['schemas']['Feature'],
) {
super(message, 301);
this.name = 'FeatureMovedError';
this.newFeatureId = newFeatureId;
this.feature = feature;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* 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-feature-gone-split-page.js';
import {WebstatusFeatureGoneSplitPage} from '../webstatus-feature-gone-split-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 NewFeature = {name: string; url: string};

describe('webstatus-feature-gone-split-page', () => {
const newFeatureIds = 'feature1,feature2';
const mockNewFeatures: NewFeature[] = [
{name: 'Feature One', url: '/features/feature1'},
{name: 'Feature Two', url: '/features/feature2'},
];

it('renders the correct error message', async () => {
const component = await fixture<WebstatusFeatureGoneSplitPage>(
html`<webstatus-feature-gone-split-page
.location=${{search: ''}}
></webstatus-feature-gone-split-page>`,
);

expect(
component.shadowRoot
?.querySelector('#error-status-code')
?.textContent?.trim(),
).to.equal('410');

expect(
component.shadowRoot
?.querySelector('#error-headline')
?.textContent?.trim(),
).to.equal('Feature Gone');

expect(
component.shadowRoot
?.querySelector('#error-detailed-message .error-message')
?.textContent?.trim(),
).to.equal('This feature has been split into multiple new features.');
});

it('displays "Loading new features..." when the API request is pending', async () => {
const component = await createComponentWithMockedNewFeatures(
newFeatureIds,
[],
{stayPending: true},
);

const loadingMessage =
component.shadowRoot?.querySelector('.loading-message');
expect(loadingMessage).to.exist;
expect(loadingMessage?.textContent?.trim()).to.equal(
'Loading new features...',
);
});

it('renders new features when API returns results', async () => {
const component = await createComponentWithMockedNewFeatures(
newFeatureIds,
mockNewFeatures,
);

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 action buttons', async () => {
const component = await fixture<WebstatusFeatureGoneSplitPage>(html`
<webstatus-feature-gone-split-page
.location=${{search: ''}}
></webstatus-feature-gone-split-page>
`);

expect(component.shadowRoot?.querySelector('#error-action-home-btn')).to
.exist;
expect(component.shadowRoot?.querySelector('#error-action-report')).to
.exist;
});

it('report issue button links to GitHub', async () => {
const component = await fixture<WebstatusFeatureGoneSplitPage>(html`
<webstatus-feature-gone-split-page
.location=${{search: ''}}
></webstatus-feature-gone-split-page>
`);

const reportButton = component.shadowRoot?.querySelector(
'#error-action-report',
);
expect(reportButton?.getAttribute('href')).to.equal(GITHUB_REPO_ISSUE_LINK);
});

async function createComponentWithMockedNewFeatures(
newFeatureIds: string,
mockData: NewFeature[],
options: {stayPending?: boolean} = {},
): Promise<WebstatusFeatureGoneSplitPage> {
const component = await fixture<WebstatusFeatureGoneSplitPage>(html`
<webstatus-feature-gone-split-page
.location=${{search: `?new_features=${newFeatureIds}`}}
></webstatus-feature-gone-split-page>
`);

component._newFeatures = new Task<[APIClient, string], NewFeature[]>(
component,
{
args: () => [undefined as unknown as APIClient, newFeatureIds],
task: async () => {
if (options.stayPending) return new Promise(() => {});
return mockData;
},
},
);

component._newFeatures.run();
await component.updateComplete;
return component;
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import '../webstatus-feature-page.js';
import sinon from 'sinon';
import {WPTRunMetric} from '../../api/client.js';
import {render} from 'lit';
import {FeatureMovedError} from '../../api/errors.js';

describe('webstatus-feature-page', () => {
let el: FeaturePage;
Expand Down Expand Up @@ -479,4 +480,61 @@ describe('webstatus-feature-page', () => {
expect(links[1].getAttribute('href')).to.equal('/features/other-feature');
});
});

describe('redirects', () => {
it('shows a redirect notice if the redirected_from URL parameter is present', async () => {
const redirectedEl = await fixture<FeaturePage>(
html`<webstatus-feature-page
.location=${{
params: {featureId: 'new-feature'},
search: '?redirected_from=old-feature',
pathname: '/features/new-feature',
}}
></webstatus-feature-page>`,
);
await redirectedEl.updateComplete;

const alert = redirectedEl.shadowRoot?.querySelector('sl-alert');
expect(alert).to.not.be.null;
// Normalize whitespace to avoid issues with formatting in the template literal.
const text = alert?.textContent?.replace(/\s+/g, ' ').trim();
expect(text).to.contain(
'You have been redirected from an old feature ID (old-feature)',
);
});

it('does not show a redirect notice if the URL parameter is not present', async () => {
const alert = el.shadowRoot?.querySelector('sl-alert');
expect(alert).to.be.null;
});

it('handleMovedFeature updates the history and component state', async () => {
const pushStateSpy = sinon.spy(history, 'pushState');
const newFeature = {
feature_id: 'new-feature',
name: 'New Feature',
description: 'A new feature',
browser_implementations: {},
wpt: {},
};
const fakeError = new FeatureMovedError('foo', 'new-feature', newFeature);

el.handleMovedFeature('old-feature', fakeError);

expect(el.featureId).to.equal('new-feature');
expect(el.oldFeatureId).to.equal('old-feature');
expect(el.feature).to.deep.equal(newFeature);

expect(pushStateSpy).to.have.been.calledWith(
null,
'',
'/features/new-feature?redirected_from=old-feature',
);

const canonical = document.head.querySelector('link[rel="canonical"]');
expect(canonical).to.not.be.null;
expect(canonical?.getAttribute('href')).to.equal('/features/new-feature');
expect(document.title).to.equal('New Feature');
});
});
});
Loading
Loading