Skip to content

Commit 3e0de91

Browse files
fix(react-ontario): fix sanitizeHref hyphen-stripping bug; add utils, i18n, and context tests
The /[ -]/ regex character-class range (space 0x20 to hyphen 0x2D) was silently stripping hyphens from all URLs — corrupting dashed hostnames, path segments, tel: numbers, and hash fragments. Fixed to /[\x00-\x1f\x7f]/ (C0 control chars + DEL only), matching the react-uswds implementation. The bug was caught by four new test files added to close the test suite gaps vs react-uswds: - sanitizeHref.test.ts — protocol allowlist, blocking, console.warn coverage - sanitizeHref.regression.test.ts — hyphen preservation regression guard - safeHtml.test.ts — DOMPurify integration, SEC-1A/B/C/D, resetPurifier - safeHtmlLazyInit.test.ts — LWS-4 lazy init behaviour - i18n.test.tsx — I18N-1/I18N-2 contract for all 20 language-aware components - OntarioContext.test.tsx — adds renderHook isolation tests alongside existing component-render tests 1477 tests passing (was 1352). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent df4318a commit 3e0de91

7 files changed

Lines changed: 685 additions & 9 deletions

File tree

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/// <reference types="@testing-library/jest-dom" />
2+
/*
3+
* Copyright (c) 2026, Salesforce GPS Accelerators.
4+
* All rights reserved.
5+
* Licensed under the Apache License, Version 2.0.
6+
*
7+
* i18n contract tests for all language-aware Ontario components.
8+
*
9+
* Contract (I18N-1 / I18N-2):
10+
* - When rendered inside OntarioProvider, the component must forward the
11+
* context language to the upstream Stencil element (data-prop-language).
12+
* - An explicit `language` prop on the component must override the context value.
13+
* - When rendered outside any OntarioProvider, the component must default to "en".
14+
*
15+
* Each describe block covers one language-aware component with three tests.
16+
*/
17+
import { render, screen } from '@testing-library/react';
18+
import { describe, it, expect, vi, beforeEach } from 'vitest';
19+
import { OntarioProvider } from '../../context/OntarioContext';
20+
import { OntarioAccordion } from '../OntarioAccordion/OntarioAccordion';
21+
import { OntarioBackToTop } from '../OntarioBackToTop/OntarioBackToTop';
22+
import { OntarioCheckboxes } from '../OntarioCheckboxes/OntarioCheckboxes';
23+
import { OntarioDateInput } from '../OntarioDateInput/OntarioDateInput';
24+
import { OntarioDropdownList } from '../OntarioDropdownList/OntarioDropdownList';
25+
import { OntarioFooter } from '../OntarioFooter/OntarioFooter';
26+
import { OntarioHeader } from '../OntarioHeader/OntarioHeader';
27+
import { OntarioHeaderMenuTabs } from '../OntarioHeaderMenuTabs/OntarioHeaderMenuTabs';
28+
import { OntarioHeaderOverflowMenu } from '../OntarioHeaderOverflowMenu/OntarioHeaderOverflowMenu';
29+
import { OntarioHintExpander } from '../OntarioHintExpander/OntarioHintExpander';
30+
import { OntarioInput } from '../OntarioInput/OntarioInput';
31+
import { OntarioLanguageToggle } from '../OntarioLanguageToggle/OntarioLanguageToggle';
32+
import { OntarioLoadingIndicator } from '../OntarioLoadingIndicator/OntarioLoadingIndicator';
33+
import { OntarioRadioButtons } from '../OntarioRadioButtons/OntarioRadioButtons';
34+
import { OntarioSearchBox } from '../OntarioSearchBox/OntarioSearchBox';
35+
import { OntarioStepIndicator } from '../OntarioStepIndicator/OntarioStepIndicator';
36+
import { OntarioSummaryList } from '../OntarioSummaryList/OntarioSummaryList';
37+
import { OntarioTask } from '../OntarioTask/OntarioTask';
38+
import { OntarioTaskList } from '../OntarioTaskList/OntarioTaskList';
39+
import { OntarioTextarea } from '../OntarioTextarea/OntarioTextarea';
40+
41+
// ---------------------------------------------------------------------------
42+
// Single vi.mock covers all 20 components — all are in the same upstream package
43+
// ---------------------------------------------------------------------------
44+
vi.mock('@ongov/ontario-design-system-component-library-react', () => {
45+
const makeStub = (testId: string) =>
46+
vi.fn((props: Record<string, unknown>) => {
47+
const attrs: Record<string, string> = {};
48+
for (const [k, v] of Object.entries(props)) {
49+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
50+
attrs[`data-prop-${k.toLowerCase()}`] = String(v);
51+
}
52+
}
53+
return <div data-testid={testId} {...attrs} />;
54+
});
55+
56+
return {
57+
OntarioAccordion: makeStub('mock-ontarioaccoordion-i18n'),
58+
OntarioBackToTop: makeStub('mock-ontariobacktotop-i18n'),
59+
OntarioCheckboxes: makeStub('mock-ontariocheckboxes-i18n'),
60+
OntarioDateInput: makeStub('mock-ontariodateinput-i18n'),
61+
OntarioDropdownList: makeStub('mock-ontariodropdownlist-i18n'),
62+
OntarioFooter: makeStub('mock-ontariofooter-i18n'),
63+
OntarioHeader: makeStub('mock-ontarioheader-i18n'),
64+
OntarioHeaderMenuTabs: makeStub('mock-ontarioheadermenutabs-i18n'),
65+
OntarioHeaderOverflowMenu: makeStub('mock-ontarioheaderoverflowmenu-i18n'),
66+
OntarioHintExpander: makeStub('mock-ontariohintexpander-i18n'),
67+
OntarioInput: makeStub('mock-ontarioinput-i18n'),
68+
OntarioLanguageToggle: makeStub('mock-ontariolanguagetoggle-i18n'),
69+
OntarioLoadingIndicator: makeStub('mock-ontarioloadingindicator-i18n'),
70+
OntarioRadioButtons: makeStub('mock-ontarioradiobuttons-i18n'),
71+
OntarioSearchBox: makeStub('mock-ontariosearchbox-i18n'),
72+
OntarioStepIndicator: makeStub('mock-ontariostepindicator-i18n'),
73+
OntarioSummaryList: makeStub('mock-ontariosummarylist-i18n'),
74+
OntarioTask: makeStub('mock-ontariotask-i18n'),
75+
OntarioTaskList: makeStub('mock-ontariotasklist-i18n'),
76+
OntarioTextarea: makeStub('mock-ontariotextarea-i18n'),
77+
setAssetPath: vi.fn(),
78+
};
79+
});
80+
81+
// ---------------------------------------------------------------------------
82+
// Shared menu items fixture for header components
83+
// ---------------------------------------------------------------------------
84+
const MENU_ITEMS = [{ title: 'Home', href: 'https://ontario.ca' }];
85+
86+
// ---------------------------------------------------------------------------
87+
// Helper to assert the three-test i18n contract for a single component
88+
// ---------------------------------------------------------------------------
89+
function assertI18nContract(
90+
testId: string,
91+
renderFn: (language?: 'en' | 'fr') => React.ReactElement,
92+
) {
93+
it('defaults to "en" when rendered outside any OntarioProvider', () => {
94+
render(renderFn());
95+
expect(screen.getByTestId(testId)).toHaveAttribute('data-prop-language', 'en');
96+
});
97+
98+
it('reads "fr" from OntarioProvider when no language prop is set', () => {
99+
render(<OntarioProvider language="fr">{renderFn()}</OntarioProvider>);
100+
expect(screen.getByTestId(testId)).toHaveAttribute('data-prop-language', 'fr');
101+
});
102+
103+
it('explicit language prop overrides OntarioProvider value', () => {
104+
render(<OntarioProvider language="fr">{renderFn('en')}</OntarioProvider>);
105+
expect(screen.getByTestId(testId)).toHaveAttribute('data-prop-language', 'en');
106+
});
107+
}
108+
109+
// ---------------------------------------------------------------------------
110+
// Per-component i18n suites
111+
// ---------------------------------------------------------------------------
112+
113+
describe('OntarioAccordion — i18n contract', () => {
114+
assertI18nContract('mock-ontarioaccoordion-i18n', (language) => (
115+
<OntarioAccordion {...(language ? { language } : {})} />
116+
));
117+
});
118+
119+
describe('OntarioBackToTop — i18n contract', () => {
120+
assertI18nContract('mock-ontariobacktotop-i18n', (language) => (
121+
<OntarioBackToTop {...(language ? { language } : {})} />
122+
));
123+
});
124+
125+
describe('OntarioCheckboxes — i18n contract', () => {
126+
assertI18nContract('mock-ontariocheckboxes-i18n', (language) => (
127+
<OntarioCheckboxes {...(language ? { language } : {})} />
128+
));
129+
});
130+
131+
describe('OntarioDateInput — i18n contract', () => {
132+
assertI18nContract('mock-ontariodateinput-i18n', (language) => (
133+
<OntarioDateInput {...(language ? { language } : {})} />
134+
));
135+
});
136+
137+
describe('OntarioDropdownList — i18n contract', () => {
138+
assertI18nContract('mock-ontariodropdownlist-i18n', (language) => (
139+
<OntarioDropdownList {...(language ? { language } : {})} />
140+
));
141+
});
142+
143+
describe('OntarioFooter — i18n contract', () => {
144+
assertI18nContract('mock-ontariofooter-i18n', (language) => (
145+
<OntarioFooter {...(language ? { language } : {})} />
146+
));
147+
});
148+
149+
describe('OntarioHeader — i18n contract', () => {
150+
assertI18nContract('mock-ontarioheader-i18n', (language) => (
151+
<OntarioHeader {...(language ? { language } : {})} />
152+
));
153+
});
154+
155+
describe('OntarioHeaderMenuTabs — i18n contract', () => {
156+
assertI18nContract('mock-ontarioheadermenutabs-i18n', (language) => (
157+
<OntarioHeaderMenuTabs menuItems={MENU_ITEMS} {...(language ? { language } : {})} />
158+
));
159+
});
160+
161+
describe('OntarioHeaderOverflowMenu — i18n contract', () => {
162+
assertI18nContract('mock-ontarioheaderoverflowmenu-i18n', (language) => (
163+
<OntarioHeaderOverflowMenu menuItems={MENU_ITEMS} {...(language ? { language } : {})} />
164+
));
165+
});
166+
167+
describe('OntarioHintExpander — i18n contract', () => {
168+
assertI18nContract('mock-ontariohintexpander-i18n', (language) => (
169+
<OntarioHintExpander {...(language ? { language } : {})} />
170+
));
171+
});
172+
173+
describe('OntarioInput — i18n contract', () => {
174+
assertI18nContract('mock-ontarioinput-i18n', (language) => (
175+
<OntarioInput {...(language ? { language } : {})} />
176+
));
177+
});
178+
179+
describe('OntarioLanguageToggle — i18n contract', () => {
180+
assertI18nContract('mock-ontariolanguagetoggle-i18n', (language) => (
181+
<OntarioLanguageToggle {...(language ? { language } : {})} />
182+
));
183+
});
184+
185+
describe('OntarioLoadingIndicator — i18n contract', () => {
186+
assertI18nContract('mock-ontarioloadingindicator-i18n', (language) => (
187+
<OntarioLoadingIndicator {...(language ? { language } : {})} />
188+
));
189+
});
190+
191+
describe('OntarioRadioButtons — i18n contract', () => {
192+
assertI18nContract('mock-ontarioradiobuttons-i18n', (language) => (
193+
<OntarioRadioButtons {...(language ? { language } : {})} />
194+
));
195+
});
196+
197+
describe('OntarioSearchBox — i18n contract', () => {
198+
assertI18nContract('mock-ontariosearchbox-i18n', (language) => (
199+
<OntarioSearchBox {...(language ? { language } : {})} />
200+
));
201+
});
202+
203+
describe('OntarioStepIndicator — i18n contract', () => {
204+
assertI18nContract('mock-ontariostepindicator-i18n', (language) => (
205+
<OntarioStepIndicator {...(language ? { language } : {})} />
206+
));
207+
});
208+
209+
describe('OntarioSummaryList — i18n contract', () => {
210+
assertI18nContract('mock-ontariosummarylist-i18n', (language) => (
211+
<OntarioSummaryList {...(language ? { language } : {})} />
212+
));
213+
});
214+
215+
describe('OntarioTask — i18n contract', () => {
216+
assertI18nContract('mock-ontariotask-i18n', (language) => (
217+
<OntarioTask {...(language ? { language } : {})} />
218+
));
219+
});
220+
221+
describe('OntarioTaskList — i18n contract', () => {
222+
assertI18nContract('mock-ontariotasklist-i18n', (language) => (
223+
<OntarioTaskList {...(language ? { language } : {})} />
224+
));
225+
});
226+
227+
describe('OntarioTextarea — i18n contract', () => {
228+
assertI18nContract('mock-ontariotextarea-i18n', (language) => (
229+
<OntarioTextarea {...(language ? { language } : {})} />
230+
));
231+
});

packages/react-ontario/src/context/__tests__/OntarioContext.test.tsx

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* All rights reserved.
55
* Licensed under the Apache License, Version 2.0.
66
*/
7-
import { render, screen } from '@testing-library/react';
7+
import { render, screen, renderHook } from '@testing-library/react';
88
import { describe, it, expect, vi } from 'vitest';
99
import { OntarioProvider, useOntario } from '../OntarioContext';
1010

@@ -124,16 +124,11 @@ describe('useOntario()', () => {
124124

125125
describe('explicit language override pattern', () => {
126126
it('a consumer prop overrides the context language value', () => {
127-
// This demonstrates the recommended pattern for components that accept
128-
// an optional `language` prop: the prop wins over context when provided.
129127
render(
130128
<OntarioProvider language="fr">
131-
{/* overrideLanguage="en" simulates a component prop taking precedence */}
132129
<LanguageConsumer overrideLanguage="en" />
133130
</OntarioProvider>,
134131
);
135-
136-
// Context says 'fr', but the explicit override wins.
137132
expect(screen.getByTestId('lang')).toHaveTextContent('en');
138133
});
139134

@@ -143,7 +138,50 @@ describe('explicit language override pattern', () => {
143138
<LanguageConsumer />
144139
</OntarioProvider>,
145140
);
146-
147141
expect(screen.getByTestId('lang')).toHaveTextContent('fr');
148142
});
149143
});
144+
145+
// ---------------------------------------------------------------------------
146+
// renderHook — hook isolation tests (mirrors react-uswds context test style)
147+
// ---------------------------------------------------------------------------
148+
describe('useOntario inside a Provider (renderHook)', () => {
149+
it('returns the language value passed to the Provider', () => {
150+
const wrapper = ({ children }: { children: React.ReactNode }) => (
151+
<OntarioProvider language="fr">{children}</OntarioProvider>
152+
);
153+
const { result } = renderHook(() => useOntario(), { wrapper });
154+
expect(result.current.language).toBe('fr');
155+
});
156+
157+
it('returns the assetsBasePath value passed to the Provider', () => {
158+
const wrapper = ({ children }: { children: React.ReactNode }) => (
159+
<OntarioProvider assetsBasePath="/static/ontario">{children}</OntarioProvider>
160+
);
161+
const { result } = renderHook(() => useOntario(), { wrapper });
162+
expect(result.current.assetsBasePath).toBe('/static/ontario');
163+
});
164+
165+
it('reflects both language and assetsBasePath when both are provided', () => {
166+
const wrapper = ({ children }: { children: React.ReactNode }) => (
167+
<OntarioProvider language="fr" assetsBasePath="https://cdn.example.com/ontario">
168+
{children}
169+
</OntarioProvider>
170+
);
171+
const { result } = renderHook(() => useOntario(), { wrapper });
172+
expect(result.current.language).toBe('fr');
173+
expect(result.current.assetsBasePath).toBe('https://cdn.example.com/ontario');
174+
});
175+
});
176+
177+
describe('useOntario outside a Provider (renderHook)', () => {
178+
it('returns "en" for language (context default)', () => {
179+
const { result } = renderHook(() => useOntario());
180+
expect(result.current.language).toBe('en');
181+
});
182+
183+
it('returns empty string for assetsBasePath (context default)', () => {
184+
const { result } = renderHook(() => useOntario());
185+
expect(result.current.assetsBasePath).toBe('');
186+
});
187+
});

0 commit comments

Comments
 (0)