Skip to content

Commit 7e4ef96

Browse files
fix(qti3): isolate stimulus styles and catalogs
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7613ab0 commit 7e4ef96

11 files changed

Lines changed: 367 additions & 22 deletions

File tree

docs/certification/public-coverage-matrix.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"packages/item-player/tests/core/pnp.test.ts",
4545
"packages/item-player/tests/core/delivery-context.test.ts",
4646
"packages/item-player/tests/core/stimulusRender.test.ts",
47+
"packages/item-player/tests/core/stylesheetRender.test.ts",
4748
"packages/item-player/tests/core/glossaryTriggers.test.ts"
4849
]
4950
},
@@ -335,11 +336,11 @@
335336
"level": "Advanced",
336337
"feature": "I4 Shared Stimulus",
337338
"officialPath": "qti3.0/Advanced/I4 Shared Stimulus/",
338-
"publicTests": ["packages/item-player/src/extraction/extractors/conformance-qti3-advanced.test.ts", "packages/ims-cp-core/tests/qti3-shared-content.test.ts", "packages/item-player/tests/core/delivery-context.test.ts", "packages/item-player/tests/core/stimulusRender.test.ts", "apps/demo/tests/playwright/public-certification.pw.ts"],
339+
"publicTests": ["packages/item-player/src/extraction/extractors/conformance-qti3-advanced.test.ts", "packages/ims-cp-core/tests/qti3-shared-content.test.ts", "packages/item-player/tests/core/delivery-context.test.ts", "packages/item-player/tests/core/stimulusRender.test.ts", "packages/item-player/tests/core/stylesheetRender.test.ts", "apps/demo/tests/playwright/public-certification.pw.ts"],
339340
"fixturePackages": [],
340341
"e2eRequired": true,
341342
"commandIds": ["item-clean-room", "package-clean-room", "item-runtime-clean-room", "browser-visible"],
342-
"notes": "Covers stimulus reference preservation, structured qti-assessment-stimulus parsing, stylesheet/catalog extraction, package-relative stylesheet/stimulus path gates, unsafe relative asset/catalog file-href removal, item-runtime consumption of resolved stimulus/catalog context, and tested stimulus insertion/override behavior. Browser-visible shared stimulus rendering and runtime CSS scoping evidence is tracked in docs/certification/qti3-remaining-parity-evidence.md."
343+
"notes": "Covers stimulus reference preservation, structured qti-assessment-stimulus parsing, stylesheet/catalog extraction, package-relative stylesheet/stimulus path gates, unsafe relative asset/catalog file-href removal, item-runtime consumption of resolved stimulus/catalog context, tested stimulus insertion/override behavior, scoped catalog lookup for stimulus ID collisions, and instance-isolated/stimulus-local scoped runtime stylesheet CSS. Browser-visible shared stimulus rendering evidence is tracked in docs/certification/qti3-remaining-parity-evidence.md."
343344
},
344345
{
345346
"id": "qti30-advanced-i17",

docs/certification/qti3-remaining-parity-evidence.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ and private conformance evidence remain the gates.
6363
| PNP/access-for-all display and content supports | `item-player` parses and applies PNP; host handles platform supports emitted by user action | Additive `PnpProfile` fields and `Player.updatePnp()` behavior | Parser/apply tests for canonical aliases, display/text preferences, magnification, host-routed supports, host-defined catalog usages, optional language preference, reserved-usage filtering, documented `html` event payloads, and dynamic rebinding cleanup | Verify expanded PNP support behavior without leaking vendor fixture names | First code pass; review findings fixed; focused tests pass |
6464
| Effective time limits and itemSessionControl precedence | `assessment-player` resolves effective controls; backend adapter validates final timing decisions | Additive optional backend API timing state, expiry scope, and effective item control snapshots | Unit/integration tests for assessment/testPart/section/item-ref precedence, restore, expiry, late submission, and extended time | Verify published backend adapter contract honors official timing cases | Planned |
6565
| Backend-authoritative timing enforcement | `assessment-player` sends timing evidence; backend adapter accepts, rejects, or finalizes | Additive optional `BackendAdapter`, submit/finalize request, and session timing fields with defaults for existing adapters | Adapter contract tests for server-side accept/reject/finalize decisions and persisted elapsed time | Verify private conformance runs against published package behavior, not local state | Planned |
66-
| Shared stimulus runtime delivery | `ims-cp-core` extracts stimulus metadata, `ims-cp-browser` resolves resources, `assessment-player` passes resolved context, `item-player` renders | Additive resolved stimulus body/style/catalog inputs | Parser and renderer tests for multiple stimuli, docking order, undocked content, validation messages, explicit compatibility overrides, class merging, and asset resolution | Verify shared stimulus delivery with published packages | First code pass for body insertion and package-reference gates; browser rendering and stylesheet application remain |
67-
| Stylesheet and asset scoping | `ims-cp-browser` resolves and classifies URLs, `item-player` enforces render policy | Additive security policy configuration for package styles/assets | Tests for package-relative styles, blocked remote/unsafe URLs, path traversal, CSS escaping, and scoped stylesheet application | Verify official stylesheets do not require unsafe public defaults | First code pass for package-relative path gates and unsafe stylesheet/stimulus refs; runtime CSS scoping remains Stage 4 |
68-
| Scoped catalog delivery | `ims-cp-core` extracts catalog XML, `item-player` scopes and resolves catalog entries | Additive rich catalog source model and host catalog event detail | Tests for item-local precedence, shared stimulus catalog fallback, language fallback, duplicate ID scoping, inactive PNP gating, sanitized host event content, unsafe usage-name rejection, and unsafe relative `qti-file-href` removal | Verify official catalog/glossary cases with published packages | First code pass for host-event details and package-relative catalog file gates; scoped stimulus catalog runtime remains Stage 4 |
66+
| Shared stimulus runtime delivery | `ims-cp-core` extracts stimulus metadata, `ims-cp-browser` resolves resources, `assessment-player` passes resolved context, `item-player` renders | Additive resolved stimulus body/style/catalog inputs | Parser and renderer tests for multiple stimuli, docking order, undocked content, validation messages, explicit compatibility overrides, class merging, scoped catalog lookup, stylesheet scoping, and asset resolution | Verify shared stimulus delivery with published packages | First code pass plus review fixes for body insertion, stylesheet application, scoped catalog lookup, and package-reference gates; browser rendering evidence remains |
67+
| Stylesheet and asset scoping | `ims-cp-browser` resolves and classifies URLs, `item-player` enforces render policy | Additive security policy configuration for package styles/assets | Tests for package-relative styles, blocked remote/unsafe URLs, path traversal, CSS escaping, instance-isolated scoping, stimulus-local scoping, and scoped stylesheet application | Verify official stylesheets do not require unsafe public defaults | Runtime scoped stylesheet application has focused unit coverage; browser-visible CSS evidence remains Stage 5 |
68+
| Scoped catalog delivery | `ims-cp-core` extracts catalog XML, `item-player` scopes and resolves catalog entries | Additive rich catalog source model and host catalog event detail | Tests for item-local precedence, shared stimulus catalog fallback, language fallback, duplicate ID scoping, inactive PNP gating, sanitized host event content, unsafe usage-name rejection, and unsafe relative `qti-file-href` removal | Verify official catalog/glossary cases with published packages | Stimulus-scoped lookup now has focused runtime coverage; browser evidence remains Stage 5 |
6969
| Browser-visible PNP/catalog UI | `item-player` emits accessible UI/events, `apps/demo` exercises candidate-facing behavior | Component events and accessible names | Playwright tests for keyboard operation, Escape/focus return, distinct labels, dynamic PNP toggles, host events, no answer-key leakage, and target size | Manual AT signoff after private conformance stability | Planned |
7070
| Accessible timer UI | `assessment-player` emits warning/expiry state, `apps/demo` renders accessible status | Time warning/expiry events and shell rendering | Browser tests for localized warnings, live regions, no involuntary focus movement, disabled/late state, reduced motion, zoom/reflow, and contrast | Verify no timing conformance behavior depends on inaccessible UI-only state | Planned |
7171
| Final qti3 package parity checklist | docs/certification | No runtime API | Checklist comparing each `qti3-*` package feature area to public evidence, intentional divergences, private status, and residual risk | Completed only after private conformance against published versions | Planned |
@@ -92,8 +92,10 @@ answer keys are not exposed through:
9292
stylesheet/catalog scoping, and runtime renderer consumption. Unit coverage now
9393
includes package-relative stylesheet/stimulus path gates and unsafe relative
9494
asset/catalog file-reference removal, plus item-runtime stimulus insertion,
95-
explicit override precedence, class merging, and undocked stimulus ordering.
96-
Browser-visible runtime rendering and stylesheet application remain Stage 4/5.
95+
explicit override precedence, class merging, undocked stimulus ordering, and
96+
instance-isolated and stimulus-local runtime stylesheet CSS, plus scoped catalog
97+
lookup for item/stimulus ID collisions. Browser-visible rendering evidence remains
98+
Stage 5.
9799
- `qti30-advanced-t5`: expand for effective itemSessionControl precedence
98100
across testPart, section, and item-ref scopes.
99101
- `qti30-advanced-t12`: expand for item/section/test time-limit precedence,

packages/ims-cp-core/src/qti3-shared-content.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export interface ResolvedQtiStylesheetRef extends QtiStylesheetRef {
2929
/** Source scope for precedence and diagnostics. */
3030
source: 'item' | 'stimulus';
3131
stimulusIdentifier?: string;
32+
/** Sanitized stylesheet text when the package stylesheet was readable and safe. */
33+
cssText?: string;
3234
}
3335

3436
export interface ResolvedQtiCatalogSource {
@@ -146,7 +148,8 @@ export function createResolvedItemDeliveryContext(
146148
extractQtiStylesheets(options.itemXml),
147149
itemHref,
148150
'item',
149-
validationMessages
151+
validationMessages,
152+
options.readText
150153
);
151154

152155
for (const itemCatalogXml of extractCatalogInfoXml(options.itemXml)) {
@@ -173,6 +176,7 @@ export function createResolvedItemDeliveryContext(
173176
resolvedHref,
174177
'stimulus',
175178
validationMessages,
179+
options.readText,
176180
ref.identifier
177181
);
178182
const catalogSource = parsed.catalogInfoXml
@@ -279,6 +283,7 @@ function resolveStylesheetRefs(
279283
baseHref: string,
280284
source: 'item' | 'stimulus',
281285
validationMessages: string[],
286+
readText?: ResolveItemDeliveryContextOptions['readText'],
282287
stimulusIdentifier?: string
283288
): ResolvedQtiStylesheetRef[] {
284289
const resolved: ResolvedQtiStylesheetRef[] = [];
@@ -290,16 +295,31 @@ function resolveStylesheetRefs(
290295
`${source === 'item' ? 'Item' : `Stimulus ${stimulusIdentifier ?? ''}`} stylesheet`
291296
);
292297
if (!resolvedHref) continue;
298+
const cssText = readText ? readText(resolvedHref) : undefined;
299+
const safeCssText = cssText === undefined || cssText === null
300+
? undefined
301+
: sanitizeStylesheetCss(cssText, validationMessages, resolvedHref);
293302
resolved.push({
294303
...style,
295304
resolvedHref,
296305
source,
297306
stimulusIdentifier,
307+
...(safeCssText ? { cssText: safeCssText } : {}),
298308
});
299309
}
300310
return resolved;
301311
}
302312

313+
function sanitizeStylesheetCss(css: string, validationMessages: string[], resolvedHref: string): string | null {
314+
if (!css.trim()) return '';
315+
const blockedPattern = /@import\b|url\s*\(|<\/style|expression\s*\(|javascript\s*:|vbscript\s*:|data\s*:/i;
316+
if (blockedPattern.test(css)) {
317+
validationMessages.push(`Stylesheet blocked by policy: ${resolvedHref}.`);
318+
return null;
319+
}
320+
return css;
321+
}
322+
303323
function isPackageRelativeHref(href: string): boolean {
304324
const value = href.trim();
305325
return Boolean(value) && !value.startsWith('/') && !value.startsWith('//') && !/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(value);

packages/ims-cp-core/tests/qti3-shared-content.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,12 @@ describe('QTI 3 shared content parsing', () => {
113113
const context = createResolvedItemDeliveryContext({
114114
itemXml,
115115
itemHref: 'items/unit/item.xml',
116-
readText: (path) => (path === 'items/stimuli/passage.xml' ? stimulusXml : null),
116+
readText: (path) => {
117+
if (path === 'items/stimuli/passage.xml') return stimulusXml;
118+
if (path === 'items/unit/item.css') return '.item-term { font-weight: bold; }';
119+
if (path === 'items/stimuli/passage.css') return '.stimulus-term { color: blue; }';
120+
return null;
121+
},
117122
resolveAssetUrl: (path) => `asset://${path}`,
118123
});
119124

@@ -123,10 +128,40 @@ describe('QTI 3 shared content parsing', () => {
123128
'items/unit/item.css',
124129
'items/stimuli/passage.css',
125130
]);
131+
expect(context.stylesheets.map((style) => style.cssText)).toEqual([
132+
'.item-term { font-weight: bold; }',
133+
'.stimulus-term { color: blue; }',
134+
]);
126135
expect(context.catalogSources.map((source) => source.scope)).toEqual(['item', 'stimulus']);
127136
expect(context.validationMessages).toEqual([]);
128137
});
129138

139+
test('blocks unsafe stylesheet CSS text while preserving safe stylesheet metadata', () => {
140+
const itemXml = `<qti-assessment-item identifier="item-1">
141+
<qti-stylesheet href="safe.css"/>
142+
<qti-stylesheet href="unsafe.css"/>
143+
<qti-item-body><p>Question body.</p></qti-item-body>
144+
</qti-assessment-item>`;
145+
146+
const context = createResolvedItemDeliveryContext({
147+
itemXml,
148+
itemHref: 'items/unit/item.xml',
149+
readText: (path) => {
150+
if (path === 'items/unit/safe.css') return '.term { color: #123456; }';
151+
if (path === 'items/unit/unsafe.css') return '@import "https://evil.example/unsafe.css"; .term { color: red; }';
152+
return null;
153+
},
154+
});
155+
156+
expect(context.stylesheets.map((style) => style.resolvedHref)).toEqual([
157+
'items/unit/safe.css',
158+
'items/unit/unsafe.css',
159+
]);
160+
expect(context.stylesheets[0].cssText).toBe('.term { color: #123456; }');
161+
expect(context.stylesheets[1].cssText).toBeUndefined();
162+
expect(context.validationMessages.join(' ')).toContain('Stylesheet blocked by policy: items/unit/unsafe.css');
163+
});
164+
130165
test('blocks unsafe stimulus and stylesheet package references', () => {
131166
const itemXml = `<qti-assessment-item identifier="item-1">
132167
<qti-stylesheet href="javascript:alert"/>

packages/item-player/src/catalog/applyGlossaryTriggers.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export function applyGlossaryTriggers(container: HTMLElement, player: Player): (
6161
for (const termEl of Array.from(terms)) {
6262
const idref = termEl.getAttribute('data-catalog-idref');
6363
if (!idref) continue;
64+
const stimulusIdentifier = getStimulusIdentifier(termEl);
6465

6566
// Wrap term in a relative-positioned span so we can position popup against it
6667
const wrapper = document.createElement('span');
@@ -80,7 +81,7 @@ export function applyGlossaryTriggers(container: HTMLElement, player: Player): (
8081
currentCleanup = null;
8182
return;
8283
}
83-
const html = player.getCatalogEntry(idref, 'glossary-on-screen');
84+
const html = player.getCatalogEntry(idref, 'glossary-on-screen', undefined, { stimulusIdentifier });
8485
if (html !== null) {
8586
currentCleanup = mountPopup(wrapper, termEl.textContent ?? idref, html, btn, player, () => {
8687
currentCleanup = null;
@@ -100,7 +101,7 @@ export function applyGlossaryTriggers(container: HTMLElement, player: Player): (
100101
currentCleanup = null;
101102
return;
102103
}
103-
const html = player.getCatalogEntry(idref, 'keyword-translation', lang);
104+
const html = player.getCatalogEntry(idref, 'keyword-translation', lang, { stimulusIdentifier });
104105
if (html !== null) {
105106
currentCleanup = mountPopup(wrapper, termEl.textContent ?? idref, html, btn, player, () => {
106107
currentCleanup = null;
@@ -119,7 +120,7 @@ export function applyGlossaryTriggers(container: HTMLElement, player: Player): (
119120
currentCleanup = null;
120121
return;
121122
}
122-
const html = player.getCatalogEntry(idref, ILLUSTRATED_GLOSSARY_USAGE);
123+
const html = player.getCatalogEntry(idref, ILLUSTRATED_GLOSSARY_USAGE, undefined, { stimulusIdentifier });
123124
if (html !== null) {
124125
currentCleanup = mountPopup(wrapper, termEl.textContent ?? idref, html, btn, player, () => {
125126
currentCleanup = null;
@@ -134,11 +135,11 @@ export function applyGlossaryTriggers(container: HTMLElement, player: Player): (
134135
addHostCatalogSupportButton(wrapper, termEl, idref, {
135136
...support,
136137
languageCode: getSupportLanguageCode(preference),
137-
}, player);
138+
}, player, stimulusIdentifier);
138139
}
139140

140141
for (const support of hostSupports) {
141-
addHostCatalogSupportButton(wrapper, termEl, idref, support, player);
142+
addHostCatalogSupportButton(wrapper, termEl, idref, support, player, stimulusIdentifier);
142143
}
143144
}
144145

@@ -175,9 +176,10 @@ function addHostCatalogSupportButton(
175176
termEl: HTMLElement,
176177
idref: string,
177178
support: HostCatalogSupport,
178-
player: Player
179+
player: Player,
180+
stimulusIdentifier?: string
179181
): void {
180-
const html = player.getCatalogEntry(idref, support.usage, support.languageCode);
182+
const html = player.getCatalogEntry(idref, support.usage, support.languageCode, { stimulusIdentifier });
181183
if (html === null) return;
182184
const btn = createTriggerButton(termEl.textContent ?? idref, support.usage, support.label, support.text);
183185
wrapper.appendChild(btn);
@@ -192,6 +194,12 @@ function addHostCatalogSupportButton(
192194
});
193195
}
194196

197+
function getStimulusIdentifier(termEl: HTMLElement): string | undefined {
198+
const scopeEl = termEl.closest?.('[data-stimulus-idref]');
199+
const identifier = scopeEl?.getAttribute?.('data-stimulus-idref')?.trim();
200+
return identifier || undefined;
201+
}
202+
195203
function getHostCatalogSupports(platformSupports: Record<string, CatalogSupportPreference | undefined>): HostCatalogSupport[] {
196204
return Object.entries(platformSupports)
197205
.map(([usage, preference]) => ({ usage: normalizeCatalogUsage(usage), preference }))

packages/item-player/src/components/ItemBody.svelte

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
<script lang="ts" module>
2+
let nextItemBodyScopeId = 0;
3+
</script>
4+
15
<script lang="ts">
26
import type { InteractionData } from '../types';
37
import type { Player } from '../core/Player';
@@ -12,6 +16,7 @@
1216
import { typesetAction } from './actions/typesetAction';
1317
import { glossaryAction } from '../catalog/glossaryAction';
1418
import { assignProps } from './utils/assignProps';
19+
import { buildScopedStylesheetCss } from './utils/stylesheetRender';
1520
import { buildEffectiveStimulusContent, injectStimulusContent } from './utils/stimulusRender';
1621
import { getRoleCapabilities } from '../core/rolePolicy';
1722
import InlineChoice from '../interactions/inline-choice/InlineChoice.svelte';
@@ -51,6 +56,8 @@
5156
stimulusContent = {},
5257
deliveryContext,
5358
}: Props = $props();
59+
const itemBodyScope = `qti-item-body-${++nextItemBodyScopeId}`;
60+
const itemBodyScopeSelector = `[data-qti-item-body-scope="${itemBodyScope}"]`;
5461
5562
// Normalize heuristics configuration with defaults
5663
const heuristics = $derived(normalizeHeuristicsConfig(heuristicsConfig));
@@ -98,13 +105,17 @@
98105
const itemBodyHtml = $derived.by(() => {
99106
let html = player.getItemBodyHtml();
100107
const resolvedDeliveryContext = deliveryContext ?? player.getDeliveryContext();
108+
const stylesheetCss = buildScopedStylesheetCss(resolvedDeliveryContext, itemBodyScopeSelector);
101109
const effectiveStimulusContent = buildEffectiveStimulusContent(
102110
resolvedDeliveryContext,
103111
stimulusContent,
104112
(content) => player.sanitizeHtmlContent(content)
105113
);
106114
107115
html = injectStimulusContent(html, effectiveStimulusContent);
116+
if (stylesheetCss) {
117+
html = `<style data-qti-stylesheets="resolved">${stylesheetCss}</style>${html}`;
118+
}
108119
109120
// Process feedbackInline elements - conditionally show/hide based on outcome values
110121
html = processFeedbackInline(html, {
@@ -205,7 +216,13 @@
205216
}
206217
</script>
207218

208-
<div bind:this={rootEl} class="qti-item-body" use:typesetAction={{ typeset }} use:glossaryAction={{ player }}>
219+
<div
220+
bind:this={rootEl}
221+
class="qti-item-body"
222+
data-qti-item-body-scope={itemBodyScope}
223+
use:typesetAction={{ typeset }}
224+
use:glossaryAction={{ player }}
225+
>
209226
<!-- Item body with inline interactions -->
210227
<div class="prose max-w-none mb-4">
211228
<div class="inline-interaction-container">

0 commit comments

Comments
 (0)