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:
Родитель
71bfdfdd66
Коммит
ead9793c0f
|
@ -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;
|
||||
}
|
Загрузка…
Ссылка в новой задаче