Skip to content

Commit 03a55a5

Browse files
committed
[Frontend SSR] Implement Server-Side Rendered Release Notes Page & HTML Templates
## Summary Implements the public Server-Side Rendered (SSR) Release Notes page (`GET /release-notes/<int:milestone>` and `GET /release-notes`) using hyphenated URL paths to match existing `developer.chrome.com/release-notes` URL conventions. Provides fast initial load time, zero JS bundle dependencies, search engine SEO indexing, public CDN caching (`HTTP_CACHE_TYPE = 'public'`), a Symmetrical Navigation Strip (Channel Quick-Jumps + Milestone Combobox Selector), M151 cutoff HTTP 302 redirect handling, and responsive category-grouped feature cards styled via native `main.css` design tokens. ## Key Changes - **Controller (`pages/releasenotes.py` & `main.py`)**: Implements `ReleaseNotesHandler(basehandlers.FlaskHandler)` with `HTTP_CACHE_TYPE = 'public'` registered under `/release-notes/<int:milestone>` and `/release-notes`. Assembles template context with channel milestone mappings (`stable`, `beta`, `dev`), M151 cutoff redirect (`MIN_SSR_RELEASE_NOTES_MILESTONE = 151`), and category-grouped features. - **Template (`templates/release-notes.html`)**: Extends `_base.html`. Implements the Symmetrical Navigation Strip, native `main.css` button classes, hyphenated `/release-notes/` href links, and responsive Flexbox feature cards. - **Testing (`pages/releasenotes_test.py` & Playwright)**: Added 16 Python unit tests covering public cache configuration, M151 cutoff HTTP 302 redirects, parameter resolution, and a modular Playwright test suite (`packages/playwright/tests/chromedash-release-notes-ssr_pwtest.js`). TAG=agy CONV=86f63625-bdb5-4d50-ac8d-2f8ca5128ca9
1 parent a3efa20 commit 03a55a5

13 files changed

Lines changed: 986 additions & 4 deletions

File tree

framework/seo.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""SEO metadata models and helpers for Server-Side Rendering (SSR)."""
16+
17+
from dataclasses import asdict, dataclass
18+
from typing import Any
19+
20+
21+
@dataclass(frozen=True)
22+
class Metadata:
23+
"""Strongly-typed container for page SEO and social sharing metadata."""
24+
25+
canonical_url: str | None = None
26+
seo_title: str | None = None
27+
seo_description: str | None = None
28+
site_logo_url: str | None = None
29+
og_type: str = 'website'
30+
schema_type: str = 'WebPage'
31+
twitter_card: str = 'summary_large_image'
32+
33+
def to_dict(self) -> dict[str, Any]:
34+
"""Export non-None metadata fields as a template context dictionary."""
35+
return {k: v for k, v in asdict(self).items() if v is not None}

framework/seo_test.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Unit tests for framework/seo.py Metadata dataclass."""
16+
17+
import testing_config # noqa: F401, I001
18+
19+
from framework import seo
20+
import settings
21+
22+
23+
class SEOMetadataTest(testing_config.CustomTestCase):
24+
"""Unit tests for seo.Metadata dataclass."""
25+
26+
def test_instantiation_defaults(self):
27+
"""It applies default values for og_type, schema_type, and twitter_card."""
28+
metadata = seo.Metadata(
29+
canonical_url='https://chromestatus.com/release-notes/151',
30+
seo_title='Chrome 151 Release Notes',
31+
seo_description='Release notes description.',
32+
site_logo_url='https://chromestatus.com/static/img/crstatus_192.png',
33+
)
34+
35+
self.assertEqual(
36+
'https://chromestatus.com/release-notes/151', metadata.canonical_url
37+
)
38+
self.assertEqual('Chrome 151 Release Notes', metadata.seo_title)
39+
self.assertEqual('Release notes description.', metadata.seo_description)
40+
self.assertEqual(
41+
'https://chromestatus.com/static/img/crstatus_192.png',
42+
metadata.site_logo_url,
43+
)
44+
self.assertEqual('website', metadata.og_type)
45+
self.assertEqual('WebPage', metadata.schema_type)
46+
self.assertEqual('summary_large_image', metadata.twitter_card)
47+
48+
def test_to_dict__exports_non_none_fields(self):
49+
"""It exports metadata attributes as a template context dictionary."""
50+
metadata = seo.Metadata(
51+
canonical_url=f'{settings.SITE_URL.rstrip("/")}/feature/123',
52+
seo_title='Feature Detail',
53+
seo_description='Feature Description',
54+
)
55+
56+
d = metadata.to_dict()
57+
self.assertIn('canonical_url', d)
58+
self.assertIn('seo_title', d)
59+
self.assertIn('seo_description', d)
60+
self.assertNotIn('site_logo_url', d)
61+
self.assertEqual('website', d['og_type'])
62+
self.assertEqual('WebPage', d['schema_type'])
63+
self.assertEqual('summary_large_image', d['twitter_card'])

main.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,14 @@
6868
reminders,
6969
search_fulltext,
7070
)
71-
from pages import featuredetail, guide, ot_requests, sitemap, users
71+
from pages import (
72+
featuredetail,
73+
guide,
74+
ot_requests,
75+
releasenotes,
76+
sitemap,
77+
users,
78+
)
7279

7380
# Patch treading library to work-around bug with Google Cloud Logging.
7481
original_delete = threading.Thread._delete # type: ignore
@@ -432,6 +439,8 @@ def safe_delete(self):
432439
),
433440
Route('/sitemap.txt', sitemap.SitemapHandler),
434441
Route('/feature-ssr/<int:feature_id>', featuredetail.FeatureDetailHandler),
442+
Route('/release-notes/<int:milestone>', releasenotes.ReleaseNotesHandler),
443+
Route('/release-notes', releasenotes.ReleaseNotesHandler),
435444
]
436445

437446
internals_routes: list[Route] = [
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// @ts-check
2+
import {test, expect} from '@playwright/test';
3+
import {
4+
captureConsoleMessages,
5+
login,
6+
logout,
7+
createNewFeature,
8+
} from './test_utils';
9+
10+
test.describe('Release Notes SSR Page', () => {
11+
let sharedFeatureName = '';
12+
13+
test.beforeAll(async ({browser}) => {
14+
const page = await browser.newPage();
15+
captureConsoleMessages(page);
16+
await login(page);
17+
18+
sharedFeatureName = `Release Notes Test Feature M151 ${Date.now()}`;
19+
await createNewFeature(page, {
20+
name: sharedFeatureName,
21+
summary: 'Test summary description for milestone 151 release notes.',
22+
milestone: 151,
23+
});
24+
25+
await logout(page);
26+
await page.close();
27+
});
28+
29+
test.beforeEach(async ({page}, testInfo) => {
30+
captureConsoleMessages(page);
31+
testInfo.setTimeout(60000);
32+
await login(page);
33+
});
34+
35+
test.afterEach(async ({page}) => {
36+
await logout(page);
37+
});
38+
39+
test('should render symmetrical navigation strip with channel quick-jumps and active pills', async ({
40+
page,
41+
}) => {
42+
await page.goto('/release-notes/151', {timeout: 30000});
43+
44+
const pageHeading = page.getByRole('heading', {
45+
name: 'Chrome 151 Release Notes',
46+
});
47+
await expect(pageHeading).toBeVisible();
48+
49+
const navStrip = page.locator('.nav-strip');
50+
await expect(navStrip).toBeVisible();
51+
52+
const stableLink = page.getByRole('link', {name: /Stable/i});
53+
const betaLink = page.getByRole('link', {name: /Beta/i});
54+
const devLink = page.getByRole('link', {name: /Dev/i});
55+
56+
await expect(stableLink).toBeVisible();
57+
await expect(betaLink).toBeVisible();
58+
await expect(devLink).toBeVisible();
59+
60+
await expect(stableLink).toHaveAttribute('href', /\/release-notes\/\d+/);
61+
await expect(betaLink).toHaveAttribute('href', /\/release-notes\/\d+/);
62+
await expect(devLink).toHaveAttribute('href', /\/release-notes\/\d+/);
63+
64+
const activePill = page.locator('.channel-pill.active');
65+
await expect(activePill).toBeVisible();
66+
});
67+
68+
test('should navigate to selected milestone via milestone combobox selector dropdown', async ({
69+
page,
70+
}) => {
71+
await page.goto('/release-notes/151', {timeout: 30000});
72+
73+
const milestoneSelect = page.getByLabel('Milestone:');
74+
await expect(milestoneSelect).toBeVisible();
75+
await expect(milestoneSelect).toHaveValue('151');
76+
77+
await milestoneSelect.selectOption('152');
78+
await page.waitForURL(/\/release-notes\/152/, {timeout: 10000});
79+
80+
const newHeading = page.getByRole('heading', {
81+
name: 'Chrome 152 Release Notes',
82+
});
83+
await expect(newHeading).toBeVisible();
84+
await expect(milestoneSelect).toHaveValue('152');
85+
});
86+
87+
test('should render created feature card and navigate to feature detail page', async ({
88+
page,
89+
}) => {
90+
await page.goto('/release-notes/151', {timeout: 30000});
91+
92+
const featureLink = page.getByRole('link', {name: sharedFeatureName});
93+
await expect(featureLink).toBeVisible({timeout: 20000});
94+
await expect(featureLink).toHaveClass(/feature-name/);
95+
96+
await featureLink.click();
97+
await page.waitForURL(/\/feature\/\d+/, {timeout: 10000});
98+
99+
const featureDetail = page.locator('chromedash-feature-detail');
100+
await expect(featureDetail).toBeVisible();
101+
});
102+
103+
test('should render documentation and spec links with target=_blank and rel=noopener', async ({
104+
page,
105+
}) => {
106+
await page.goto('/release-notes/151', {timeout: 30000});
107+
108+
const docLinks = page.locator('.feature-doc-links a');
109+
const docLinksCount = await docLinks.count();
110+
111+
if (docLinksCount > 0) {
112+
for (let i = 0; i < docLinksCount; i++) {
113+
const link = docLinks.nth(i);
114+
await expect(link).toHaveAttribute('target', '_blank');
115+
await expect(link).toHaveAttribute('rel', 'noopener noreferrer');
116+
}
117+
}
118+
});
119+
120+
test('should perform HTTP 302 redirect for historical milestones prior to M151 cutoff', async ({
121+
page,
122+
}) => {
123+
await page.goto('/release-notes/150', {timeout: 30000});
124+
await expect(page).toHaveURL(/developer\.chrome\.com\/release-notes\/150/);
125+
});
126+
127+
test('should render category section anchors and feature card ID anchors', async ({
128+
page,
129+
}) => {
130+
await page.goto('/release-notes/151', {timeout: 30000});
131+
132+
const categorySections = page.locator('.category-section');
133+
const categoryCount = await categorySections.count();
134+
135+
if (categoryCount > 0) {
136+
const firstCategory = categorySections.first();
137+
await expect(firstCategory).toHaveAttribute('id', /^[a-z0-9-]+$/);
138+
139+
const categoryTitle = firstCategory.locator('.category-title');
140+
await expect(categoryTitle).toBeVisible();
141+
await expect(categoryTitle).toHaveAttribute(
142+
'id',
143+
/^category-[a-z0-9-]+$/
144+
);
145+
146+
const featureCards = firstCategory.locator('.feature-card');
147+
const cardCount = await featureCards.count();
148+
if (cardCount > 0) {
149+
await expect(featureCards.first()).toHaveAttribute(
150+
'id',
151+
/^feature-\d+$/
152+
);
153+
}
154+
}
155+
});
156+
157+
test('should render empty state banner when milestone has no release notes features', async ({
158+
page,
159+
}) => {
160+
await page.goto('/release-notes/999', {timeout: 30000});
161+
162+
const emptyState = page.locator('.empty-state');
163+
await expect(emptyState).toBeVisible();
164+
await expect(emptyState.getByRole('heading')).toHaveText(
165+
'No release notes features available for Chrome 999.'
166+
);
167+
});
168+
});

packages/playwright/tests/test_utils.js

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -324,16 +324,20 @@ export async function enterWebFeatureId(page) {
324324
/**
325325
* Create a new feature, starting from top-level page, ending up on feature page.
326326
* @param {import('@playwright/test').Page} page
327+
* @param {{name?: string, summary?: string, milestone?: number|string}} [options]
327328
*/
328-
export async function createNewFeature(page) {
329+
export async function createNewFeature(page, options = {}) {
329330
await gotoNewFeaturePage(page);
331+
const name = options.name || 'Test feature name';
332+
const summary = options.summary || 'Test summary description';
333+
330334
// Enter feature name
331335
const featureNameInput = page.locator('input[name="name"]');
332-
await featureNameInput.fill('Test feature name');
336+
await featureNameInput.fill(name);
333337

334338
// Enter summary description
335339
const summaryInput = page.locator('textarea[name="summary"]');
336-
await summaryInput.fill('Test summary description');
340+
await summaryInput.fill(summary);
337341

338342
await enterBlinkComponent(page);
339343
await enterWebFeatureId(page);
@@ -362,6 +366,29 @@ export async function createNewFeature(page) {
362366
}
363367

364368
await expect(detail).toBeVisible({timeout: 30000});
369+
370+
// If an explicit milestone was provided, set the shipping milestone on the feature.
371+
if (options.milestone) {
372+
const url = page.url();
373+
const featureIdMatch = url.match(/\/feature\/(\d+)/);
374+
if (featureIdMatch) {
375+
const featureId = featureIdMatch[1];
376+
await page.goto(`/guide/editall/${featureId}`);
377+
const shippedInput = page
378+
.locator(
379+
'input[name="shipped_milestone"], input[name="shipped_desktop"], input[name="desktop_first"]'
380+
)
381+
.first();
382+
if (await shippedInput.isVisible()) {
383+
await shippedInput.fill(String(options.milestone));
384+
const submitBtn = page
385+
.locator('input[type="submit"], button[type="submit"]')
386+
.first();
387+
await submitBtn.click();
388+
await page.waitForURL(`**/feature/${featureId}`);
389+
}
390+
}
391+
}
365392
}
366393

367394
/**

0 commit comments

Comments
 (0)