Unblock Storybook-in-tests with l10n

Accessing next/headers in tests results in an error because it's
not in a Server Component context, so calling our regular
`getL10n()` functions from inside Storybook would result in test
failures. Instead, I replaced that call with a function that just
loads the English strings directly, rather than looking at request
headers.
This commit is contained in:
Vincent 2023-07-10 16:37:01 +02:00 коммит произвёл Vincent
Родитель 71bfdfdd66
Коммит ead9793c0f
8 изменённых файлов: 169 добавлений и 75 удалений

Просмотреть файл

@ -9,28 +9,14 @@ import "../src/app/globals.css";
import { L10nProvider } from "../src/contextProviders/localization";
import { metropolis } from "../src/app/fonts/Metropolis/metropolis";
import { ReactAriaI18nProvider } from "../src/contextProviders/react-aria";
import { getEnL10nBundlesSync } from "../src/app/functions/server/mockL10n";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
function loadL10nModule() {
if (process.env.NODE_ENV === "test") {
// In Jest, loading this module throws errors because it uses
// require.context(). Since we don't need to look at actual strings in our
// unit tests, we can just skip loading the bundles.
return {
getL10nBundles: () => [],
getLocale: () => "en",
};
}
return require("../src/app/functions/server/l10n");
}
const AppDecorator: Exclude<Preview["decorators"], undefined>[0] = (
storyFn
) => {
const { getL10nBundles, getLocale } = loadL10nModule();
const l10nBundles = getL10nBundles();
const l10nBundles = getEnL10nBundlesSync();
useEffect(() => {
// We have to add these classes to the body, rather than simply wrapping the
@ -44,9 +30,7 @@ const AppDecorator: Exclude<Preview["decorators"], undefined>[0] = (
return (
<L10nProvider bundleSources={l10nBundles}>
<ReactAriaI18nProvider locale={getLocale(l10nBundles)}>
{storyFn()}
</ReactAriaI18nProvider>
<ReactAriaI18nProvider locale="en">{storyFn()}</ReactAriaI18nProvider>
</L10nProvider>
);
};

Просмотреть файл

@ -13,6 +13,8 @@ main-nav-link-dashboard-label = Dashboard
main-nav-link-faq-label = FAQs
main-nav-link-faq-tooltip = Frequently asked questions
mobile-menu-label = Main menu
toolbar-app-picker-trigger-title = { -brand-mozilla } apps and services
toolbar-app-picker-product-vpn = { -brand-mozilla-vpn }
toolbar-app-picker-product-relay = { -brand-relay }

Просмотреть файл

@ -20,7 +20,7 @@ it("passes the axe accessibility test suite on step 2", async () => {
const ComposedOnboarding = composeStory(Onboarding, Meta);
const { container } = render(<ComposedOnboarding />);
const explainerTrigger = screen.getByRole("button", {
name: "onboarding-get-started-cta-label",
name: "Start my free scan",
});
await user.click(explainerTrigger);
expect(await axe(container)).toHaveNoViolations();
@ -33,7 +33,7 @@ it("can open a dialog with more information", async () => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
const explainerTrigger = screen.getByRole("button", {
name: "onboarding-get-started-content-explainer",
name: "See how we protect your data",
});
await user.click(explainerTrigger);
expect(screen.getByRole("dialog")).toBeInTheDocument();

Просмотреть файл

@ -8,8 +8,8 @@ import { View as DashboardEl } from "./View";
import { HibpLikeDbBreach } from "../../../../../../utils/hibp";
import { ScanResult } from "../../../../../functions/server/onerep";
import { Shell } from "../../../Shell";
import { getL10n } from "../../../../../functions/server/l10n";
import { StateAbbr } from "../../../../../../utils/states";
import { getEnL10nSync } from "../../../../../functions/server/mockL10n";
const meta: Meta<typeof DashboardEl> = {
title: "Pages/Dashboard",
@ -67,7 +67,7 @@ const BreachMockItem: HibpLikeDbBreach = {
export const Dashboard: Story = {
render: () => (
<Shell l10n={getL10n()} session={null}>
<Shell l10n={getEnL10nSync()} session={null}>
<DashboardEl
user={{ email: "example@example.com" }}
userBreaches={{

Просмотреть файл

@ -4,56 +4,12 @@
import { it, expect } from "@jest/globals";
import { render } from "@testing-library/react";
import { composeStory } from "@storybook/react";
import { axe } from "jest-axe";
import { View } from "./View";
import { HibpLikeDbBreach } from "../../../../../../utils/hibp";
import { L10nProvider } from "../../../../../../contextProviders/localization";
import Meta, { Dashboard } from "./Dashboard.stories";
it("passes the axe accessibility test suite", async () => {
const BreachMockItem: HibpLikeDbBreach = {
AddedDate: new Date("2023-07-10"),
BreachDate: "11/09/23",
DataClasses: [],
Description: "",
Domain: "",
Id: 0,
IsFabricated: false,
IsMalware: false,
IsRetired: false,
IsSensitive: false,
IsSpamList: false,
IsVerified: false,
LogoPath: "",
ModifiedDate: new Date("2023-07-10"),
Name: "",
PwnCount: 0,
Title: "Twitter",
};
const { container } = render(
<L10nProvider bundleSources={[]}>
<View
user={{ email: "example@example.com" }}
userBreaches={{
emailVerifiedCount: 0,
emailTotalCount: 0,
emailSelectIndex: 0,
breachesData: {
unverifiedEmails: [],
verifiedEmails: [
{
breaches: [BreachMockItem],
email: "test@example.com",
id: 0,
primary: true,
verified: true,
},
],
},
}}
locale={"en"}
/>
</L10nProvider>
);
const ComposedDashboard = composeStory(Dashboard, Meta);
const { container } = render(<ComposedDashboard />);
expect(await axe(container)).toHaveNoViolations();
});

Просмотреть файл

@ -63,7 +63,10 @@ export const MobileShell = (props: Props) => {
</div>
</header>
<div className={styles.nonHeader}>
<nav className={styles.mainMenuLayer}>
<nav
className={styles.mainMenuLayer}
aria-label={l10n.getString("mobile-menu-label")}
>
<div className={styles.mainMenu}>
<ul>
<li>

Просмотреть файл

@ -49,9 +49,7 @@ function loadSource(filename: string): string {
*/
export function getL10nBundles(): LocaleData[] {
const acceptLangHeader =
process.env.STORYBOOK === "true"
? navigator.languages.join(",")
: headers().get("Accept-Language");
process.env.STORYBOOK === "true" ? "en" : headers().get("Accept-Language");
const bundleSources: Record<string, string[]> = {};

Просмотреть файл

@ -0,0 +1,151 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { FluentBundle, FluentResource } from "@fluent/bundle";
import { MarkupParser, ReactLocalization } from "@fluent/react";
import { Fragment, createElement } from "react";
import type { readdirSync, readFileSync } from "node:fs";
import type { resolve } from "node:path";
import { ExtendedReactLocalization, GetFragment } from "../../hooks/l10n";
import type { LocaleData } from "./l10n";
// This code only runs in a Webpack and Node context, and we explicitly adjust
// the modules we import based on that, without going async - so we need `require`.
/* eslint-disable @typescript-eslint/no-var-requires */
export function getEnL10nBundlesSync(): LocaleData[] {
return process.env.STORYBOOK === "true"
? getEnL10nBundlesInWebpackContext()
: getEnL10nBundlesInNodeContext();
}
export function getEnL10nBundlesInWebpackContext(): LocaleData[] {
const referenceStringsContext: { keys: () => string[] } & ((
path: string
// `require` isn't usually valid JS, so skip type checking for that:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => string) = (require as any).context(
"../../../../locales/en",
true,
/\.ftl$/
);
const pendingTranslationsContext: { keys: () => string[] } & ((
path: string
// `require` isn't usually valid JS, so skip type checking for that:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => string) = (require as any).context(
"../../../../locales-pending",
true,
/\.ftl$/
);
const referenceSourceFilenames = referenceStringsContext.keys();
const pendingSourceFilenames = pendingTranslationsContext.keys();
console.log({ referenceSourceFilenames, pendingSourceFilenames });
const bundleSources: string[] = referenceSourceFilenames
.map((filePath) => referenceStringsContext(filePath))
.concat(
pendingSourceFilenames.map((filePath) =>
pendingTranslationsContext(filePath)
)
);
return [
{
locale: "en",
bundleSources: bundleSources,
},
];
}
export function getEnL10nBundlesInNodeContext(): LocaleData[] {
const {
readdirSync: nodeReaddirSync,
readFileSync: nodeReadFileSync,
}: {
readdirSync: typeof readdirSync;
readFileSync: typeof readFileSync;
} = require("fs");
const { resolve: resolvePath }: { resolve: typeof resolve } = require("path");
const referenceStringsPath = resolvePath(
__dirname,
"../../../../locales/en/"
);
const pendingStringsPath = resolvePath(
__dirname,
"../../../../locales-pending/"
);
const ftlPaths = nodeReaddirSync(referenceStringsPath)
.map((filename) => resolvePath(referenceStringsPath, filename))
.concat(
nodeReaddirSync(pendingStringsPath).map((filename) =>
resolvePath(pendingStringsPath, filename)
)
);
const bundleSources: string[] = ftlPaths.map((filePath) =>
nodeReadFileSync(filePath, "utf-8")
);
return [
{
locale: "en",
bundleSources: bundleSources,
},
];
}
const parseMarkup: MarkupParser = (str) => {
if (!str.includes("<") && !str.includes(">")) {
return [{ nodeName: "#text", textContent: str } as Node];
}
return [
{
nodeName: "#text",
textContent: str.replace(/<(.*?)>/g, ""),
} as Node,
];
};
const bundles: Record<string, FluentBundle> = {};
function getBundle(localeData: LocaleData): FluentBundle {
if (bundles[localeData.locale]) {
return bundles[localeData.locale];
}
bundles[localeData.locale] = new FluentBundle(localeData.locale);
localeData.bundleSources.forEach((bundleSource) => {
bundles[localeData.locale].addResource(new FluentResource(bundleSource));
});
return bundles[localeData.locale];
}
/**
* This function loads a ReactLocalization instance with the `en` and pending strings.
*
* This is useful in tests and Storybook, where we can't call `headers` from
* `next/headers` to determine the user's locale, and even just importing from a
* module that references it can result in an error about only being able to use
* it in Server Components.
*/
export function getEnL10nSync(): ExtendedReactLocalization {
const localeData: LocaleData[] = getEnL10nBundlesSync();
const bundles: FluentBundle[] = localeData.map((data) => getBundle(data));
// The ReactLocalization instance stores and caches the sequence of generated
// bundles. You can store it in your app's state.
const l10n = new ReactLocalization(
bundles,
// In Storybook, the Fluent bundle is generated in the browser, so we don't need
// to provide `parseMarkup`:
process.env.STORYBOOK === "true" ? undefined : parseMarkup
);
const getFragment: GetFragment = (id, args, fallback) =>
l10n.getElement(createElement(Fragment, null, fallback ?? id), id, args);
const extendedL10n: ExtendedReactLocalization =
l10n as ExtendedReactLocalization;
extendedL10n.getFragment = getFragment;
return extendedL10n;
}