Skip to content

Commit 584c783

Browse files
chore: initial setup vitest browser mode (#6419)
provider add ci Co-authored-by: Dave Samojlenko <[email protected]>
1 parent 3e632b9 commit 584c783

File tree

27 files changed

+997
-167
lines changed

27 files changed

+997
-167
lines changed

.github/workflows/vitest.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,11 @@ jobs:
3030
- name: "Install dependencies"
3131
run: yarn workspaces focus gcforms
3232

33+
- name: Install Playwright Browsers
34+
run: npx playwright install --with-deps chromium
35+
3336
- name: Vitest Tests
3437
run: yarn test:vitest
38+
39+
- name: Vitest Browser Tests
40+
run: yarn test:vitest:browser

app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses-pilot/ContentWrapper.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
"use client";
22

33
import Link from "next/link";
4-
import { useTranslation } from "react-i18next";
54
import { useResponsesContext } from "./context/ResponsesContext";
6-
import { useRouter } from "next/navigation";
5+
import { useResponsesApp } from "./context";
76
import { disableResponsesPilotMode } from "../responses/actions";
87

98
export const ContentWrapper = ({ children }: { children: React.ReactNode }) => {
109
const { formId } = useResponsesContext();
11-
const { t, i18n } = useTranslation(["form-builder-responses"]);
12-
const router = useRouter();
10+
const { t, i18n, router } = useResponsesApp();
1311

1412
const handleSwitchBack = async (e: React.MouseEvent<HTMLAnchorElement>) => {
1513
e.preventDefault();

app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses-pilot/Responses.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ export const Responses = ({ actions }: { actions?: React.ReactNode }) => {
1010

1111
if (newFormSubmissions === null) {
1212
return (
13-
<div className="mb-8 rounded-2xl border-2 border-gray-300 bg-white p-8">
13+
<div
14+
className="mb-8 rounded-2xl border-2 border-gray-300 bg-white p-8"
15+
data-testid="responses-loading"
16+
>
1417
<div className="flex items-center justify-between">
1518
<div className="w-2/3">
1619
<Skeleton className="mb-6 h-10 w-3/4" />
@@ -24,9 +27,11 @@ export const Responses = ({ actions }: { actions?: React.ReactNode }) => {
2427
}
2528

2629
return newFormSubmissions && newFormSubmissions.length > 0 ? (
27-
<div className="flex items-center justify-between">
30+
<div className="flex items-center justify-between" data-testid="responses-available">
2831
<div>
29-
<h2 className="mb-8">{t("loadKeyPage.newResponsesAvailable")}</h2>
32+
<h2 className="mb-8" data-testid="new-responses-heading">
33+
{t("loadKeyPage.newResponsesAvailable")}
34+
</h2>
3035
{actions}
3136
</div>
3237
<div>
@@ -39,9 +44,11 @@ export const Responses = ({ actions }: { actions?: React.ReactNode }) => {
3944
</div>
4045
</div>
4146
) : (
42-
<div className="flex items-center justify-between">
47+
<div className="flex items-center justify-between" data-testid="no-responses">
4348
<div>
44-
<h2 className="mb-8">{t("loadKeyPage.noNewResponsesAvailable")}</h2>
49+
<h2 className="mb-8" data-testid="no-responses-heading">
50+
{t("loadKeyPage.noNewResponsesAvailable")}
51+
</h2>
4552
{actions}
4653
</div>
4754
<div>
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"use client";
2+
3+
import { createContext, useContext, ReactNode } from "react";
4+
import { showOpenFilePicker } from "native-file-system-adapter";
5+
import { getAccessTokenFromApiKey } from "../lib/utils";
6+
import i18next from "i18next";
7+
// Import to trigger i18next initialization
8+
import "@root/i18n/client";
9+
10+
interface ResponsesAppContextType {
11+
// Navigation
12+
router: {
13+
push: (href: string) => void;
14+
replace: (href: string) => void;
15+
back: () => void;
16+
forward: () => void;
17+
refresh: () => void;
18+
prefetch: (href: string) => Promise<void>;
19+
};
20+
searchParams: URLSearchParams;
21+
22+
// i18n
23+
t: (key: string, options?: Record<string, unknown>) => string;
24+
i18n: {
25+
language: string;
26+
changeLanguage: (lang: string) => Promise<void>;
27+
};
28+
29+
// File system
30+
showOpenFilePicker: typeof showOpenFilePicker;
31+
32+
// API utilities
33+
getAccessTokenFromApiKey: typeof getAccessTokenFromApiKey;
34+
35+
// Environment
36+
apiUrl: string;
37+
isDevelopment: boolean;
38+
}
39+
40+
const BrowserResponsesAppContext = createContext<ResponsesAppContextType | null>(null);
41+
42+
interface BrowserResponsesAppProviderProps {
43+
children: ReactNode;
44+
overrides?: Partial<ResponsesAppContextType>;
45+
}
46+
47+
/**
48+
* Browser-only version of ResponsesAppProvider that uses real i18n.
49+
* Use this for Vitest browser mode testing.
50+
*/
51+
export const BrowserResponsesAppProvider = ({
52+
children,
53+
overrides = {},
54+
}: BrowserResponsesAppProviderProps) => {
55+
// Create a translation function that uses response-api namespace
56+
const t = (key: string, options?: Record<string, unknown>) => {
57+
return i18next.t(key, { ...options, ns: "response-api" });
58+
};
59+
60+
// Default mock implementations for browser testing
61+
const defaultRouter = {
62+
push: () => {},
63+
replace: () => {},
64+
back: () => {},
65+
forward: () => {},
66+
refresh: () => {},
67+
prefetch: () => Promise.resolve(),
68+
};
69+
70+
const defaultSearchParams = new URLSearchParams();
71+
72+
// Create i18n object
73+
const wrappedI18n = {
74+
language: i18next.language || "en",
75+
changeLanguage: async (lang: string) => {
76+
await i18next.changeLanguage(lang);
77+
},
78+
};
79+
80+
const value: ResponsesAppContextType = {
81+
router: defaultRouter,
82+
searchParams: defaultSearchParams,
83+
t,
84+
i18n: wrappedI18n,
85+
showOpenFilePicker,
86+
getAccessTokenFromApiKey,
87+
apiUrl: "http://localhost:3000/api",
88+
isDevelopment: true,
89+
...overrides, // Allow custom overrides for specific test needs
90+
};
91+
92+
return (
93+
<BrowserResponsesAppContext.Provider value={value}>
94+
{children}
95+
</BrowserResponsesAppContext.Provider>
96+
);
97+
};
98+
99+
/**
100+
* Hook to access the browser responses app context.
101+
* Must be used within BrowserResponsesAppProvider.
102+
*/
103+
export const useBrowserResponsesApp = () => {
104+
const context = useContext(BrowserResponsesAppContext);
105+
if (!context) {
106+
throw new Error("useBrowserResponsesApp must be used within BrowserResponsesAppProvider");
107+
}
108+
return context;
109+
};
110+
111+
// Also export as useResponsesApp for compatibility with components expecting that name
112+
export const useResponsesApp = useBrowserResponsesApp;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"use client";
2+
3+
import { createContext, useContext, ReactNode } from "react";
4+
import { useRouter, useSearchParams } from "next/navigation";
5+
import { useTranslation } from "@i18n/client";
6+
import { showOpenFilePicker } from "native-file-system-adapter";
7+
import { getAccessTokenFromApiKey } from "../lib/utils";
8+
9+
interface ResponsesAppContextType {
10+
// Navigation
11+
router: ReturnType<typeof useRouter>;
12+
searchParams: ReturnType<typeof useSearchParams>;
13+
14+
// i18n
15+
t: ReturnType<typeof useTranslation>["t"];
16+
i18n: ReturnType<typeof useTranslation>["i18n"];
17+
18+
// File system
19+
showOpenFilePicker: typeof showOpenFilePicker;
20+
21+
// API utilities
22+
getAccessTokenFromApiKey: typeof getAccessTokenFromApiKey;
23+
24+
// Environment
25+
apiUrl: string;
26+
isDevelopment: boolean;
27+
}
28+
29+
const ResponsesAppContext = createContext<ResponsesAppContextType | null>(null);
30+
31+
interface ResponsesAppProviderProps {
32+
children: ReactNode;
33+
_locale: string; // Passed for future use, currently unused
34+
namespace?: string;
35+
// Optional overrides for testing
36+
overrides?: Partial<ResponsesAppContextType>;
37+
}
38+
39+
export const ResponsesAppProvider = ({
40+
children,
41+
_locale,
42+
namespace = "response-api",
43+
overrides = {},
44+
}: ResponsesAppProviderProps) => {
45+
const router = useRouter();
46+
const searchParams = useSearchParams();
47+
const { t, i18n } = useTranslation(namespace);
48+
49+
const value: ResponsesAppContextType = {
50+
router,
51+
searchParams,
52+
t,
53+
i18n,
54+
showOpenFilePicker,
55+
getAccessTokenFromApiKey,
56+
apiUrl: process.env.NEXT_PUBLIC_API_URL ?? "",
57+
isDevelopment: process.env.NODE_ENV === "development",
58+
...overrides, // Allow test overrides
59+
};
60+
61+
return <ResponsesAppContext.Provider value={value}>{children}</ResponsesAppContext.Provider>;
62+
};
63+
64+
export const useResponsesApp = () => {
65+
const context = useContext(ResponsesAppContext);
66+
if (!context) {
67+
throw new Error("useResponsesApp must be used within ResponsesAppProvider");
68+
}
69+
return context;
70+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Barrel export for ResponsesApp context
3+
* In Vitest browser test environments, exports the Browser version
4+
* In production, exports the real ResponsesAppProvider with Next.js hooks
5+
*/
6+
7+
// Check if running in Vitest browser mode
8+
const isBrowserTest = typeof process !== "undefined" && process.env.VITEST_BROWSER === "true";
9+
10+
// Import from both
11+
import {
12+
useResponsesApp as useResponsesAppProd,
13+
ResponsesAppProvider as ProdProvider,
14+
} from "./ResponsesAppProvider";
15+
import {
16+
useResponsesApp as useResponsesAppBrowser,
17+
BrowserResponsesAppProvider,
18+
} from "./BrowserResponsesAppProvider";
19+
20+
// Export the appropriate version based on environment
21+
export const useResponsesApp = isBrowserTest ? useResponsesAppBrowser : useResponsesAppProd;
22+
export const ResponsesAppProvider = isBrowserTest ? BrowserResponsesAppProvider : ProdProvider;

app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses-pilot/layout.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { FeatureFlags } from "@lib/cache/types";
55
import { featureFlagAllowedForUser } from "@lib/userFeatureFlags";
66
import { redirect } from "next/navigation";
77
import { ResponsesProvider } from "./context/ResponsesContext";
8+
import { ResponsesAppProvider } from "./context/ResponsesAppProvider";
89
import { ContentWrapper } from "./ContentWrapper";
910
import { PilotBadge } from "@clientComponents/globals/PilotBadge";
1011
import { CompatibilityGuard } from "./guards/CompatibilityGuard";
@@ -43,12 +44,14 @@ export default async function ResponsesLayout(props: {
4344
}
4445

4546
return (
46-
<ResponsesProvider locale={locale} formId={id}>
47-
<CompatibilityGuard>
48-
<h1 className="mb-4">{t("section-title")}</h1>
49-
<PilotBadge className="mb-8" />
50-
<ContentWrapper>{props.children}</ContentWrapper>
51-
</CompatibilityGuard>
52-
</ResponsesProvider>
47+
<ResponsesAppProvider _locale={locale}>
48+
<ResponsesProvider locale={locale} formId={id}>
49+
<CompatibilityGuard>
50+
<h1 className="mb-4">{t("section-title")}</h1>
51+
<PilotBadge className="mb-8" />
52+
<ContentWrapper>{props.children}</ContentWrapper>
53+
</CompatibilityGuard>
54+
</ResponsesProvider>
55+
</ResponsesAppProvider>
5356
);
5457
}

app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses-pilot/load-key/LoadKey.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const LoadKey = ({ onLoadKey }: LoadKeyProps) => {
3535
<Button
3636
theme="secondary"
3737
className="mb-4"
38+
data-testid="load-api-key-button"
3839
onClick={async () => {
3940
// null or undefined return means user aborted - do nothing
4041
const result = await onLoadKey();

app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses-pilot/load-key/LostKeyPopover.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ export const LostKeyLink = () => {
55
const { t } = useTranslation("response-api");
66
return (
77
<p className="relative mt-2 block">
8-
<button popoverTarget="api-key-popover" className="gc-lost-key-button">
8+
<button
9+
popoverTarget="api-key-popover"
10+
className="gc-lost-key-button"
11+
data-testid="lost-key-link"
12+
>
913
<span className="underline">{t("loadKeyPage.lostKey.link")}</span>
1014
</button>
1115
</p>
@@ -29,8 +33,11 @@ export const LostKeyPopover = ({ locale, id }: { locale: string; id: string }) =
2933
id="api-key-popover"
3034
popover="auto"
3135
className="gc-lost-key-popover-content rounded-xl border-1 border-gray-500 p-10"
36+
data-testid="lost-key-popover"
3237
>
33-
<h2 className="mb-8">{t("loadKeyPage.lostKey.lostKeyTip.title")}</h2>
38+
<h2 className="mb-8" data-testid="lost-key-popover-title">
39+
{t("loadKeyPage.lostKey.lostKeyTip.title")}
40+
</h2>
3441
<p className="mb-2 font-bold">{t("loadKeyPage.lostKey.lostKeyTip.text1")}</p>
3542
<p>
3643
{t("loadKeyPage.lostKey.lostKeyTip.text2")}{" "}

app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses-pilot/load-key/ResponseActionButtons.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
import { Button } from "@clientComponents/globals";
2-
import { useRouter } from "next/navigation";
3-
4-
import { useTranslation } from "@i18n/client";
2+
import { useResponsesApp } from "../context";
53
import { useResponsesContext } from "../context/ResponsesContext";
64
import { CheckForResponsesButton } from "../components/CheckForResponsesButton";
75

86
export const ResponseActionButtons = () => {
9-
const { t } = useTranslation(["response-api", "common"]);
10-
const router = useRouter();
7+
const { t, router } = useResponsesApp();
118
const { apiClient, newFormSubmissions, resetState, locale, formId } = useResponsesContext();
129

1310
const handleBack = () => {
@@ -22,7 +19,7 @@ export const ResponseActionButtons = () => {
2219

2320
return (
2421
<div className="mt-8 flex flex-row gap-4">
25-
<Button theme="secondary" onClick={handleBack}>
22+
<Button theme="secondary" onClick={handleBack} data-testid="back-to-start-button">
2623
{t("backToStart")}
2724
</Button>
2825

@@ -33,6 +30,7 @@ export const ResponseActionButtons = () => {
3330
theme="primary"
3431
disabled={Boolean(!apiClient || (newFormSubmissions && newFormSubmissions.length === 0))}
3532
onClick={handleNext}
33+
data-testid="continue-button"
3634
>
3735
{t("continueButton")}
3836
</Button>

0 commit comments

Comments
 (0)