Skip to content
Open
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
66 changes: 48 additions & 18 deletions web/e2e/blog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ test.describe('@smoke blog', () => {
// The editor syncs the URL to its own mode on every render. If that sync
// ever stops excluding the blog, the page will be right and the address
// will say /app/basic — which is the link a reader copies.
await expect(page).toHaveURL(new RegExp(`/blog/${SLUG}$`));
await expect(page).toHaveURL(new RegExp(`/blog/${SLUG}/$`));
await expect(page).toHaveTitle(/— Stabileo$/);
});

Expand All @@ -71,7 +71,30 @@ test.describe('@smoke blog', () => {
await boot(page, `/?route=%2Fblog%2F${SLUG}`);

await expect(page.locator('.post-title')).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/blog/${SLUG}$`));
/*
* No trailing slash required here, and that is deliberate. This route
* carries no language prefix — it is the shape links handed out before
* the prefixes existed — and App.svelte restores those verbatim rather
* than rewriting somebody's saved address. A PREFIXED route is
* normalised through publicHref; see the case below.
*/
await expect(page).toHaveURL(new RegExp(`/blog/${SLUG}/?$`));

/*
* The same handoff WITH a language prefix — the form the site published
* before the slashes and that people already hold. That one IS normalised
* through publicHref, so the address bar and the page's own canonical do
* not disagree.
*
* Folded into this test rather than given its own, on purpose: one browser
* runs all 327 e2e cases with `workers: 1`, and it wedges near the end with
* `browser.newContext: Test ended`. Every added case brings that forward.
* Two navigations in one context cost nothing; a second test costs a
* context. See the note in .github/workflows/ci.yml.
*/
await page.goto(`/?route=%2Fes%2Fblog%2F${SLUG}`);
await expect(page.locator('.post-title')).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/es/blog/${SLUG}/$`));
});

test('the browser back button returns to the post', async ({ page }) => {
Expand Down Expand Up @@ -128,7 +151,7 @@ test.describe('@smoke blog', () => {
await page.locator('.landing.blog select.nav-lang').selectOption(to);

await expect(page.locator('.post-title')).toHaveText(TITLES[to]);
await expect(page).toHaveURL(new RegExp(`/${to}/blog/${SLUG}$`));
await expect(page).toHaveURL(new RegExp(`/${to}/blog/${SLUG}/$`));
await expect(page.locator('html')).toHaveAttribute('lang', to);
// The picker reports where you are, not where you were.
await expect(page.locator('.landing.blog select.nav-lang')).toHaveValue(to);
Expand All @@ -145,19 +168,26 @@ test.describe('@smoke blog', () => {

await page.locator('.landing.blog select.nav-lang').selectOption('en');

await expect(page).toHaveURL(/\/en\/blog$/);
await expect(page).toHaveURL(/\/en\/blog\/$/);
await expect(lead).toContainText(/code checks/i);
await expect(page.locator(`.post-card[data-slug="${SLUG}"] .post-card-title`)).toHaveText(TITLES.en);
});

test('the browser back button undoes a language switch', async ({ page }) => {
await boot(page, `/pt/blog/${SLUG}`);
/*
* Booted on the slashed address on purpose. In production the host 301s
* `/pt/blog/x` to it before the application loads, so that is the URL a
* reader is ever on; `vite preview` serves both, so booting unslashed
* here would test a state the site cannot actually be in — and then
* `goBack` would return to it.
*/
await boot(page, `/pt/blog/${SLUG}/`);
await page.locator('.landing.blog select.nav-lang').selectOption('es');
await expect(page).toHaveURL(new RegExp(`/es/blog/${SLUG}$`));
await expect(page).toHaveURL(new RegExp(`/es/blog/${SLUG}/$`));

await page.goBack();

await expect(page).toHaveURL(new RegExp(`/pt/blog/${SLUG}$`));
await expect(page).toHaveURL(new RegExp(`/pt/blog/${SLUG}/$`));
await expect(page.locator('.post-title')).toHaveText(TITLES.pt);
});

Expand Down Expand Up @@ -336,7 +366,7 @@ test.describe('@smoke blog', () => {
await expect(back).toBeVisible();
await expect(back).toHaveText('Blog');
// A real href, so it is crawlable and middle-clickable, not a button.
await expect(back).toHaveAttribute('href', '/es/blog');
await expect(back).toHaveAttribute('href', '/es/blog/');
// And it sits before the GitHub box, where the ask put it.
const order = await page.locator('.landing.blog .nav-actions > *').evaluateAll((els) =>
els.map((e) => e.className.toString().split(' ')[0]),
Expand All @@ -345,7 +375,7 @@ test.describe('@smoke blog', () => {
expect(order[1]).toBe('nav-gh');

await back.click();
await expect(page).toHaveURL(/\/es\/blog$/);
await expect(page).toHaveURL(/\/es\/blog\/$/);
await expect(page.locator('.post-card')).not.toHaveCount(0);
// Now on the index, it is gone.
await expect(page.locator('.landing.blog .nav-blog-link')).toHaveCount(0);
Expand All @@ -364,7 +394,7 @@ test.describe('@smoke blog', () => {
expect(data.datePublished).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(data.author.map((a: { name: string }) => a.name)).toContain('Bautista Chesta');
// It must claim the address it is actually served at, in this language.
expect(data.url).toBe(`https://stabileo.com/es/blog/${SLUG}`);
expect(data.url).toBe(`https://stabileo.com/es/blog/${SLUG}/`);
expect(data.mainEntityOfPage['@id']).toBe(data.url);
});

Expand All @@ -376,28 +406,28 @@ test.describe('@smoke blog', () => {
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(0);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href',
'https://stabileo.com/es/blog',
'https://stabileo.com/es/blog/',
);

await boot(page, `/pt/blog/${SLUG}`);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href',
`https://stabileo.com/pt/blog/${SLUG}`,
`https://stabileo.com/pt/blog/${SLUG}/`,
);
// And it points at its siblings, which is how they get discovered at all.
const alts = await page
.locator('link[rel="alternate"][hreflang]')
.evaluateAll((els) => els.map((e) => `${e.getAttribute('hreflang')} ${e.getAttribute('href')}`).sort());
expect(alts).toEqual([
`en https://stabileo.com/en/blog/${SLUG}`,
`es https://stabileo.com/es/blog/${SLUG}`,
`pt https://stabileo.com/pt/blog/${SLUG}`,
`en https://stabileo.com/en/blog/${SLUG}/`,
`es https://stabileo.com/es/blog/${SLUG}/`,
`pt https://stabileo.com/pt/blog/${SLUG}/`,
// x-default names the English version of THIS POST. English because the
// root declares /en as its canonical, so pointing the default at the
// root would name a URL that is not canonical for itself — and this
// post's path because a default that jumps to the home page sends every
// unmatched reader away from the thing they were about to be shown.
`x-default https://stabileo.com/en/blog/${SLUG}`,
`x-default https://stabileo.com/en/blog/${SLUG}/`,
]);
});

Expand Down Expand Up @@ -447,7 +477,7 @@ test.describe('@smoke blog', () => {
await expect(page.locator('.landing .hero-ctas .hero-blog')).toHaveCount(0);

await link.click();
await expect(page).toHaveURL(/\/blog$/);
await expect(page).toHaveURL(/\/blog\/$/);
await expect(page.locator('.post-card')).not.toHaveCount(0);
});

Expand All @@ -459,7 +489,7 @@ test.describe('@smoke blog', () => {
await expect(section).toBeVisible();

await section.locator('.btn-primary').click();
await expect(page).toHaveURL(/\/blog$/);
await expect(page).toHaveURL(/\/blog\/$/);
await expect(page.locator('.post-card')).not.toHaveCount(0);
});

Expand Down
10 changes: 5 additions & 5 deletions web/e2e/landing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1079,7 +1079,7 @@ test.describe('@landing landing page', () => {
expect(h.lang).toBe('en');
// The root serves English and consolidates into /en rather than
// competing with it: two URLs, one indexed page.
expect(h.canonicals).toEqual(['https://stabileo.com/en']);
expect(h.canonicals).toEqual(['https://stabileo.com/en/']);

/*
* The description is the hero's own lead, not the fallback string
Expand All @@ -1093,7 +1093,7 @@ test.describe('@landing landing page', () => {
// og:url names the page, and the page here is /en — the same address
// the canonical above declares. Sharing the root should produce a card
// for the English landing, not for a doorway.
expect(one(h, 'og:url')).toBe('https://stabileo.com/en');
expect(one(h, 'og:url')).toBe('https://stabileo.com/en/');
expect(one(h, 'og:site_name')).toBe('Stabileo');
expect(one(h, 'og:locale')).toBe('en_US');
// One tag per alternate language, which is how Open Graph reads them.
Expand Down Expand Up @@ -1134,7 +1134,7 @@ test.describe('@landing landing page', () => {
}
// One canonical, and it names the language of the page rather than the
// bare root — the root is a doorway, /en is the page.
expect(h.canonicals).toEqual(['https://stabileo.com/en']);
expect(h.canonicals).toEqual(['https://stabileo.com/en/']);
expect(h.title).toBe(EN_TITLE);
expect(one(h, 'og:title')).toBe(EN_TITLE);
// The description sharpens to the live hero copy.
Expand All @@ -1154,7 +1154,7 @@ test.describe('@landing landing page', () => {
expect(one(h, 'og:title')).toBe(h.title);
expect(one(h, 'og:locale')).toBe('es_AR');
expect(h.meta['og:locale:alternate']).toEqual(['en_US', 'pt_BR']);
expect(h.canonicals).toEqual(['https://stabileo.com/es']);
expect(h.canonicals).toEqual(['https://stabileo.com/es/']);
// The description is the hero lead, which now carries the positioning:
// a free, open platform with three modes, rather than live re-solving.
expect(one(h, 'description')).toMatch(/plataforma gratuita y abierta/);
Expand All @@ -1177,7 +1177,7 @@ test.describe('@landing landing page', () => {
expect(one(h, 'og:title')).toBe(h.title);
expect(one(h, 'og:locale')).toBe('pt_BR');
expect(h.meta['og:locale:alternate']).toEqual(['en_US', 'es_AR']);
expect(h.canonicals).toEqual(['https://stabileo.com/pt']);
expect(h.canonicals).toEqual(['https://stabileo.com/pt/']);
expect(one(h, 'twitter:description')).toBe(one(h, 'description'));
});

Expand Down
14 changes: 8 additions & 6 deletions web/e2e/prerender.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@
}

async function expectLinks(page: Page, path: string, locale: Locale) {
const canonicalPath = path === '/' ? '' : path;
// Trailing slash: the address the host serves, and now the one the page
// declares. See publicHref in src/lib/i18n/public-routes.ts.
const canonicalPath = `${path === '/' ? '' : path}/`;
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href',
`${ORIGIN}/${locale}${canonicalPath}`,
Expand Down Expand Up @@ -172,7 +174,7 @@
const data = JSON.parse(raw!);
expect(data['@type']).toBe('BlogPosting');
expect(data.inLanguage).toBe(locale);
expect(data.url).toBe(`${ORIGIN}/${locale}/blog/${SLUG}`);
expect(data.url).toBe(`${ORIGIN}/${locale}/blog/${SLUG}/`);
} finally {
await close();
}
Expand All @@ -191,12 +193,12 @@
const hrefs = await page
.locator('#prerender a[href^="/es"]')
.evaluateAll((els) => els.map((e) => e.getAttribute('href') ?? ''));
expect(hrefs, 'the landing must link to the blog index').toContain('/es/blog');
expect(hrefs, 'the landing must link to the blog index').toContain('/es/blog/');
// Named by shape rather than by slug: the section previews whatever is
// newest, so pinning a slug here would fail on the next post rather than
// on a broken link.
expect(
hrefs.filter((h) => /^\/es\/blog\/[^/]+$/.test(h)),
hrefs.filter((h) => /^\/es\/blog\/[^/]+\/$/.test(h)),
'and straight into a post, in the reader’s language',
).not.toHaveLength(0);
} finally {
Expand All @@ -211,7 +213,7 @@
try {
await expectPrerenderedShape(page);
await expect(page.locator('#prerender')).toContainText(HERO.en);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${ORIGIN}/en`);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${ORIGIN}/en/`);
} finally {
await close();
}
Expand All @@ -232,7 +234,7 @@
try {
await page.goto('/?utm_source=review');
// Generous: this is the only test here that boots the application.
await expect(page).toHaveURL(/\/pt\?utm_source=review$/, { timeout: 30_000 });
await expect(page).toHaveURL(/\/pt\/\?utm_source=review$/, { timeout: 30_000 });
} finally {
await ctx.close();
}
Expand All @@ -241,7 +243,7 @@
test('the root does not hijack an application route bounced through 404.html', async ({ browser }) => {
// A shared model link arrives as /?route=… . Redirecting it to /pt would
// throw someone opening their own structure onto the marketing page.
const ctx = await browser.newContext({ locale: 'pt-BR' });

Check failure on line 246 in web/e2e/prerender.spec.ts

View workflow job for this annotation

GitHub Actions / e2e

[chromium] › e2e/prerender.spec.ts:243:3 › @smoke prerender › the root does not hijack an application route bounced through 404.html

1) [chromium] › e2e/prerender.spec.ts:243:3 › @smoke prerender › the root does not hijack an application route bounced through 404.html Error: browser.newContext: Test ended. 244 | // A shared model link arrives as /?route=… . Redirecting it to /pt would 245 | // throw someone opening their own structure onto the marketing page. > 246 | const ctx = await browser.newContext({ locale: 'pt-BR' }); | ^ 247 | const page = await ctx.newPage(); 248 | try { 249 | await page.goto('/?route=%2Fapp%2Fbasic'); at /home/runner/work/stabileo/stabileo/web/e2e/prerender.spec.ts:246:17
const page = await ctx.newPage();
try {
await page.goto('/?route=%2Fapp%2Fbasic');
Expand Down
118 changes: 118 additions & 0 deletions web/scripts/__tests__/published-urls-resolve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Every URL the site publishes must be a URL the host serves.
*
* ── The defect this exists for ──
*
* The pages are directories on disk, and GitHub Pages answers a directory
* request with a 301 to the slashed form. That cannot be disabled. The URL
* builders emitted the UNSLASHED form, so the site published eighteen
* addresses it does not serve:
*
* sitemap says https://stabileo.com/en/blog
* the host answers 301 → https://stabileo.com/en/blog/
* and that page declared <link rel="canonical" href=".../en/blog">
*
* — a canonical pointing at a URL that redirects back to the page declaring
* it, and an hreflang set built the same way. Google resolves the chain in
* practice, but it is explicit that the canonical and the hreflang have to
* agree or it may ignore the hreflang, which is the three-language linkage
* the whole prefix scheme exists to express.
*
* ── Why it is checked on disk and not over HTTP ──
*
* Because the thing being asserted IS the disk layout. GitHub Pages serves
* `/a/b/` from `a/b/index.html` with no redirect, and `/a/b` with one. A
* local preview server does not reproduce that — `vite preview` happily
* serves both — so a test over HTTP would pass on exactly the bug that
* reached production. The file must exist at the path the URL names.
*
* Requires `dist/`: run after a build. It is part of the `build` vitest
* project for that reason.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';

const DIST = 'dist';
const ORIGIN = 'https://stabileo.com';
const SITEMAP = join(DIST, 'sitemap.xml');

const hasBuild = existsSync(SITEMAP);

/**
* A real file, not a directory.
*
* `existsSync` answers true for a directory, and a directory is precisely
* what GitHub Pages redirects. The first version of this test used it and
* passed against the very defect it was written for: `dist/en/blog` exists —
* as a folder — so `/en/blog` looked served. Caught by reverting the fix and
* watching the test not fail.
*/
function isFile(p: string): boolean {
return existsSync(p) && statSync(p).isFile();
}
const describeBuilt = hasBuild ? describe : describe.skip;

/** Every <loc> in the sitemap, as an absolute URL. */
function publishedUrls(): string[] {
const xml = readFileSync(SITEMAP, 'utf8');
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
}

/** The file GitHub Pages would serve for a path, or null if it would redirect. */
function fileServedWithoutRedirect(pathname: string): string | null {
// A path ending in '/' is served from <dir>/index.html, directly.
if (pathname.endsWith('/')) {
const file = join(DIST, pathname, 'index.html');
return isFile(file) ? file : null;
}
// Otherwise only an exact file (or its .html form) avoids the redirect.
for (const candidate of [join(DIST, pathname), `${join(DIST, pathname)}.html`]) {
if (isFile(candidate)) return candidate;
}
return null;
}

describeBuilt('every published URL is served, not redirected', () => {
it('the sitemap is not empty', () => {
expect(publishedUrls().length).toBeGreaterThan(0);
});

it('every sitemap URL resolves to a file with no redirect', () => {
const unserved = publishedUrls().filter((url) => {
const { pathname } = new URL(url);
return fileServedWithoutRedirect(pathname) === null;
});
expect(unserved, `these URLs answer 301 before they answer 200:\n${unserved.join('\n')}`).toEqual([]);
});

it('each page declares itself as canonical, at the address it is served from', () => {
const mismatched: string[] = [];
for (const url of publishedUrls()) {
const file = fileServedWithoutRedirect(new URL(url).pathname);
if (!file) continue; // reported by the test above
const html = readFileSync(file, 'utf8');
const canonical = html.match(/<link rel="canonical" href="([^"]+)"/)?.[1];
if (canonical !== url) mismatched.push(`${url}\n declares ${canonical ?? '(none)'}`);
}
expect(mismatched, `canonical disagrees with the served URL:\n${mismatched.join('\n')}`).toEqual([]);
});

it('every hreflang target is itself a served URL', () => {
// The rule Google states plainly: if the canonical and the hreflang do not
// agree, it may ignore the hreflang. Both must name served addresses.
const broken: string[] = [];
for (const url of publishedUrls()) {
const file = fileServedWithoutRedirect(new URL(url).pathname);
if (!file) continue;
const html = readFileSync(file, 'utf8');
for (const [, href] of html.matchAll(/<link rel="alternate" hreflang="[^"]+" href="([^"]+)"/g)) {
if (!href.startsWith(ORIGIN)) { broken.push(`${url} → ${href} (foreign origin)`); continue; }
if (fileServedWithoutRedirect(new URL(href).pathname) === null) {
broken.push(`${url} → ${href} (redirects)`);
}
}
}
expect(broken, `hreflang points at URLs that redirect:\n${broken.join('\n')}`).toEqual([]);
});
});
Loading
Loading