feat: implement QTI declaration serialization framework and validation utilities#5984
Conversation
…n utilities Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
|
👋 Hi @Abhishek-Punhani, thanks for contributing! For the review process to begin, please verify that the following is satisfied:
Also check that issue requirements are satisfied & you ran Pull requests that don't follow the guidelines will be closed. Reviewer assignment can take up to 2 weeks. |
|
@AlexVelezLl — I added a QTISanitizer class that wasn't in the original scope, but felt necessary. Since QTI XML is authored content that eventually gets serialized and consumed by backend and database. |
AlexVelezLl
left a comment
There was a problem hiding this comment.
Looking great! I have added some questions/comments to make this a bit more flexible. Please let me know if you have any questions/suggestions :)
| static fromXML(xmlNode) { | ||
| const identifier = xmlNode.getAttribute('identifier') ?? ''; | ||
| const baseType = xmlNode.getAttribute('base-type') ?? null; | ||
| const cardinality = xmlNode.getAttribute('cardinality') ?? 'single'; |
There was a problem hiding this comment.
I think it'd be great to have some constants declarations for cardinality and base-type.
There was a problem hiding this comment.
Yeah I had defined some functions in QTISanitizer, willl use that here
There was a problem hiding this comment.
As per #5984 (comment), I think it'd be better to have these constants defined in constants.js instead. Also, we will need to reference them when building the XML from the editor state. So better to have them defined once for all references in the editor 👐.
There was a problem hiding this comment.
I'm a little unsure about how to define the schemas per question type in this file. On one side, it'd be one additional place we should remember to edit when we create a new question type. Also, it comes at the cost of some flexibility, because if the cardinality is dynamic, then we will need to add an additional workaround for them. (For example, with text entry interactions, we will probably need to set cardinality='single' if there is just one response or 'multi' if there are many correct responses).
So, I'd say this logic should be deferred to the parse modules that each interaction is going to have. This way we keep it a bit more flexible, and it's one place less to change when we add a new interaction.
There was a problem hiding this comment.
We can add a registry factory and define the interaction for once while defining the interaction and keep cardinality as computed property and allow it to be dynamic?
There was a problem hiding this comment.
I think having a schema for the declarations may work, but I wonder if declaring a global object for all question types in a single file is the best way to define them. Perhaps we can build each schema in the parse.js file, or even declare them on each interaction descriptor. The idea is to prevent having to remember to add another entry to a global registry that holds all interactions if we already have one (the interactions descriptor registry).
The other question would be how we will handle the capabilities array on the schema, and how much it would be needed. Also, here we are defining a map of question type -> interaction again, and that would be a redundant definition, as we already cover this on the interaction descriptors.
| convertTo(newQuestionType, { keepFirst = true } = {}) { | ||
| const schema = getSchemaForType(newQuestionType); | ||
| if (!schema) throw new Error(`Unknown question type: ${newQuestionType}`); | ||
|
|
||
| const newDecl = new QTIDeclaration({ | ||
| identifier: this.identifier, | ||
| baseType: schema.baseType, | ||
| cardinality: schema.cardinality, | ||
| tag: this.tag, | ||
| }); | ||
|
|
||
| const baseTypeChanged = schema.baseType !== this.baseType; | ||
|
|
||
| const currentCR = this.correctResponse; | ||
| if ( | ||
| currentCR !== null && | ||
| !baseTypeChanged && | ||
| schema.capabilities.includes(CAPABILITY.CORRECT_RESPONSE) | ||
| ) { | ||
| const migrated = migrateValues(currentCR, this.cardinality, schema.cardinality, keepFirst); | ||
| if (migrated.length > 0) { | ||
| CorrectResponse.fromPlain(migrated, newDecl); | ||
| } | ||
| } | ||
|
|
||
| const currentMapping = this.mapping; | ||
| if ( | ||
| currentMapping !== null && | ||
| !baseTypeChanged && | ||
| schema.capabilities.includes(CAPABILITY.MAPPING) | ||
| ) { | ||
| Mapping.fromPlain(currentMapping, newDecl); | ||
| } | ||
|
|
||
| const currentAreaMapping = this.areaMapping; | ||
| if ( | ||
| currentAreaMapping !== null && | ||
| !baseTypeChanged && | ||
| schema.capabilities.includes(CAPABILITY.AREA_MAPPING) | ||
| ) { | ||
| AreaMapping.fromPlain(currentAreaMapping, newDecl); | ||
| } | ||
|
|
||
| return newDecl; | ||
| } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Module-private helpers | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** | ||
| * Migrate correctResponse values across a cardinality change. | ||
| * | ||
| * @param {string[]} values - Current values | ||
| * @param {string} fromCard - Source cardinality | ||
| * @param {string} toCard - Target cardinality | ||
| * @param {boolean} keepFirst - On downgrade to single, keep the first value | ||
| * @returns {string[]} | ||
| */ | ||
| function migrateValues(values, fromCard, toCard, keepFirst) { | ||
| const arr = Array.isArray(values) ? values : [values]; | ||
|
|
||
| if (toCard === 'single') { | ||
| return keepFirst && arr.length > 0 ? [arr[0]] : []; | ||
| } | ||
|
|
||
| // single → multiple/ordered, or multiple ↔ ordered: values transfer unchanged. | ||
| return arr; | ||
| } |
There was a problem hiding this comment.
I'm also a bit unsure about this. The general strategy to convert from question types will be to just drop the old descriptor and mount the new one with the same xml that was saved from the previous descriptor. If there are some parts of it that the new descriptor can recognize, it will use them (e.g., if the previous used baseType="identifier", and we also use baseType="identifier", then they will be "compatible" without really needing to compare both descriptors).
Also, we probably won't have a good place to call convertTo as we're trying to keep the interactionSection (the one that can actually detect the change) as independent from each interaction detail as possible.
There was a problem hiding this comment.
Yeah I guess I complicated it, just parse it once acc to new type will also work
|
|
||
| for (const child of children) { | ||
| if (typeof child === 'string') { | ||
| el.appendChild(xmlDoc.createTextNode(child)); |
There was a problem hiding this comment.
I think we may need this to set the innerHTML property at some point when we need to build the prompt nodes, but we can defer this change to when we actually need it :)
There was a problem hiding this comment.
I think we do need a sanitizer somewhere, but I think we should have a general XML sanitizer. The current sanitizer is also doing validation, and I think validation can be done within the QTIDeclaration class instead, as it would not be a general qti validation, but declaration validations. For example, we should have a BaseType constant in constants.js instead, because we will need it when we build the xml back from the interactions state, and then we can validate the baseType value at export time (on the .getXML, and we can potentially validate in other places too, but the most important one is that.) against the available values in the BaseType constant.
Identifier validation will also be needed in several places, so we can potentially move this to a helper module. But if invalid identifiers are fed into the editor, we should probably just drop them and generate proper ones (or drop them, depending on the case).
In any case, we should do XML sanitization, but not only for qti but rather for the entire XML assembly logic (also because RTE doesn't currently escape/sanitize the input/output #5956).
| constructor(values) { | ||
| /** @type {string[]} */ | ||
| this._values = values; | ||
| } | ||
|
|
||
| /** | ||
| * Parse a <qti-correct-response> element and register on the parent declaration. | ||
| * Note: the optional `interpretation` attribute (QTI 3.0 §3.1.1.2) is not | ||
| * preserved — it is not used by the authoring editor. | ||
| * | ||
| * @param {Element} xmlNode | ||
| * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration | ||
| * @returns {CorrectResponse} | ||
| */ | ||
| static fromXML(xmlNode, declaration) { | ||
| const values = [...xmlNode.querySelectorAll('qti-value')].map(v => v.textContent.trim()); | ||
| const instance = new CorrectResponse(values); | ||
| declaration.registerCapability(CAPABILITY.CORRECT_RESPONSE, instance); | ||
| return instance; | ||
| } | ||
|
|
||
| /** | ||
| * Build from plain JS data and register on the parent declaration. | ||
| * Used by QTIDeclaration.convertTo() to carry forward values without XML serialization. | ||
| * | ||
| * @param {string[]} values | ||
| * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration | ||
| * @returns {CorrectResponse} | ||
| */ | ||
| static fromPlain(values, declaration) { | ||
| const instance = new CorrectResponse(values); | ||
| declaration.registerCapability(CAPABILITY.CORRECT_RESPONSE, instance); | ||
| return instance; | ||
| } |
There was a problem hiding this comment.
I'd say we should receive the declaration as a constructor argument and call registerCapability on the constructor to prevent potential deviations between the fromXML and fromPlain. With this, I'd say that we can live without fromPlain and just directly create the object by calling the constructor on the consumer. What do you think?
| * @returns {CorrectResponse} | ||
| */ | ||
| static fromXML(xmlNode, declaration) { | ||
| const values = [...xmlNode.querySelectorAll('qti-value')].map(v => v.textContent.trim()); |
There was a problem hiding this comment.
Could we also bring the parseValues and coerceValues, and their respective counterparts for the XML build, to the QTIDeclaration class? It'd be great to have the additional coercion and validation we have there, and it would also be more comfortable to use the JS values instead of transforming them to strings before using them in the QTIDeclaration.
I think bringing support for cardinality="record" is going to be complex, so it'd probably be best not to do it right now, as there isn't yet a proper use case for it. So we can just add some errors or warnings flagging that cardinality="record" is not yet supported?
…ing QTISanitizer Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
…ilities for improved type coercion and schema alignment. Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
…erface test requirements Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
|
📢✨ Before we assign a reviewer, we'll turn on |
rtibblesbot
left a comment
There was a problem hiding this comment.
Solid declaration model with clean separation of concerns and a strong XML round-trip test pattern. Four structural issues worth addressing before merge — all suggestions, no hard bugs, CI passing.
- suggestion
mapping.js:13— circular import, easy one-line fix inline - suggestion
QTIDeclaration.js:249—forTypeuntested and out of scope for this issue - suggestion
QTISanitizer.js:1— shipped with no production consumer; duplicates constructor validation - suggestion
QTIDeclaration.js:127—coerceValue/formatValuenames conflict with the AC's explicit exclusion ofcoerceValue
UI verification not applicable — no frontend UI files changed.
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Ran a phased review pipeline over the pull request diff:
- Classified the diff to select review passes (core, frontend, backend) and whether manual QA was required
- Core review pass checked correctness, design, architecture, testing, completeness, and DRY/SRP/Rule-of-Three principles
- Specialized frontend/backend review passes applied framework-specific lenses where those files changed
- For UI changes: manual QA and an accessibility audit against a live dev server, when available
- Checked CI status and linked issue acceptance criteria
- Synthesized one review from those passes and chose the verdict from the findings, CI status, and QA evidence
| * | ||
| * @module declarations/mapping | ||
| */ | ||
| import { QTIDeclaration } from '../QTIDeclaration.js'; |
There was a problem hiding this comment.
suggestion: This import creates a confirmed cycle: QTIDeclaration.js → declarations/index.js → mapping.js → QTIDeclaration.js. It works today because QTIDeclaration is only referenced at call time (line 67), not at module evaluation, so ES module live bindings save it. But it's fragile — evaluation-order changes can produce an undefined binding.
declaration is already a parameter to fromXML, so the fix is a one-liner:
// line 67 — before
mapKey: QTIDeclaration.coerceValue(entry.getAttribute('map-key'), declaration.baseType),
// after — removes the import entirely
mapKey: declaration.coerceValues([entry.getAttribute('map-key')])[0],correctResponse.js and defaultValue.js already follow this pattern — use the instance method rather than the static one.
| * @param {*} [itemData] - Optional item data forwarded to the factory | ||
| * @returns {QTIDeclaration} | ||
| */ | ||
| static forType(questionType, identifier = 'RESPONSE', itemData = null) { |
There was a problem hiding this comment.
suggestion: forType is not described in issue #5965 and has no tests in QTIDeclaration.spec.js. It also drives all the interactions/ changes in this PR (getDeclarationSchema on the interface, getDescriptorForQuestionType in the registry, and the choice descriptor implementation). That's meaningful surface area for a method with zero test coverage.
Either add tests (forType(QuestionType.SINGLE_SELECT) returns a declaration with the right baseType/cardinality; forType throws for an unknown type) and open a separate issue for the interactions/ additions, or move the whole forType + interactions block out of this PR.
| @@ -0,0 +1,128 @@ | |||
| /** | |||
There was a problem hiding this comment.
suggestion: QTISanitizer is not mentioned in issue #5965 and no production file in this PR imports it — only its own test suite does. Meanwhile QTIDeclaration's constructor already validates identifier (line 29 with an identical /^[\p{L}_][\p{L}\p{N}_.-]*$/u regex), cardinality, and base-type inline, so the validations are duplicated rather than consolidated.
Shipping this as dead code creates maintenance surface without integration benefit. Consider either: (a) integrating it into QTIDeclaration now so the duplication is resolved, or (b) moving it to a separate PR scoped to validation/XSS hardening.
There was a problem hiding this comment.
I think we can keep it for now and take the parts of it that are useful into a more general XML sanitizer in the future.
There was a problem hiding this comment.
Understood — deferring to your call. Withdrawing the finding.
| * @param {string} baseType - One of BaseType.* (or null) | ||
| * @returns {string|number|boolean} | ||
| */ | ||
| static coerceValue(raw, baseType) { |
There was a problem hiding this comment.
suggestion: The acceptance criterion for #5965 explicitly calls out "no coerceValue" as something this class should not contain. These static methods are pure utilities (no state, no this._value writes) and are functionally different from Kolibri's stateful version — but the name is identical to what the AC says to exclude.
Renaming would make the distinction clear and bring the code into unambiguous AC compliance — e.g. parseQtiValue / serializeQtiValue at module scope, or moving them to a qtiValueUtils.js helper alongside assembleItem.js.
There was a problem hiding this comment.
The "no coerceValue" was my bad 😅. I initially thought it was only used for scoring, but it's also useful for the initial parsing :^)
| const serializer = new XMLSerializer(); | ||
| const parser = new DOMParser(); | ||
|
|
||
| function reparse(node) { |
There was a problem hiding this comment.
praise: Routing output through XMLSerializer → DOMParser and guarding on parsererror before asserting on structure is meaningfully stronger than DOM-property checks — it confirms the emitted nodes are well-formed XML that a QTI player could actually consume. Solid defensive coverage.
AlexVelezLl
left a comment
There was a problem hiding this comment.
It's looking great! No major concerns 🎉
| ); | ||
| } | ||
|
|
||
| if (cardinality === 'record') { |
There was a problem hiding this comment.
Could we use Cardinality.RECORD instead? 😅
| constructor({ | ||
| identifier, | ||
| baseType = null, | ||
| cardinality = 'single', |
There was a problem hiding this comment.
Idem, could we use Cardinality.SINGLE here?
|
|
||
| /** | ||
| * Register a named capability on this declaration. | ||
| * Called as a side-effect by declaration strategy classes during fromXML/fromPlain. |
There was a problem hiding this comment.
We should remove the fromPlain here.
| static coerceValue(raw, baseType) { | ||
| switch (baseType) { | ||
| case BaseType.INTEGER: { | ||
| const n = parseInt(raw, 10); | ||
| return Number.isNaN(n) ? raw : n; | ||
| } | ||
| case BaseType.FLOAT: { | ||
| const n = parseFloat(raw); | ||
| return Number.isNaN(n) ? raw : n; | ||
| } | ||
| case BaseType.BOOLEAN: | ||
| return raw === 'true'; | ||
| default: | ||
| // identifier, string, point, pair, directedPair, duration, file, uri | ||
| return raw; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Format a native JS value back to its QTI XML string representation. | ||
| * | ||
| * This is the inverse of coerceValue — safe to call on any JS primitive. | ||
| * | ||
| * @param {string|number|boolean} value | ||
| * @returns {string} | ||
| */ | ||
| static formatValue(value) { | ||
| if (typeof value === 'boolean') return value ? 'true' : 'false'; | ||
| return String(value ?? ''); | ||
| } | ||
|
|
||
| /** | ||
| * Coerce an array of raw XML strings using this declaration's baseType. | ||
| * | ||
| * @param {string[]} rawStrings | ||
| * @returns {Array<string|number|boolean>} | ||
| */ | ||
| coerceValues(rawStrings) { | ||
| return rawStrings.map(v => QTIDeclaration.coerceValue(v, this.baseType)); | ||
| } | ||
|
|
||
| /** | ||
| * Format an array of native JS values to QTI XML strings. | ||
| * | ||
| * @param {Array<string|number|boolean>} values | ||
| * @returns {string[]} | ||
| */ | ||
| formatValues(values) { | ||
| return values.map(QTIDeclaration.formatValue); | ||
| } |
There was a problem hiding this comment.
Oh, apologies I didn't link the Kolibri implementation 😅, I think we can just bring the coerce code from https://github.com/learningequality/kolibri/pull/14631/changes#diff-48a84a3229362f3e5828755b058e722449660ed57d2e395f167fd2fcd39528b7R232, so that we just directly have all other value types (but record cardinality) here, because we will use pair and directed pair in the future too, so best if we have everything already set up here given that the implementation in Kolibri is already there! :).
| static fromXML(xmlNode) { | ||
| const identifier = xmlNode.getAttribute('identifier') ?? ''; | ||
| const baseType = xmlNode.getAttribute('base-type') ?? null; | ||
| const cardinality = xmlNode.getAttribute('cardinality') ?? 'single'; |
There was a problem hiding this comment.
Could we use Cardinality.SINGLE here instead? :)
| @@ -0,0 +1,173 @@ | |||
| /* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */ | |||
There was a problem hiding this comment.
Could you add a brief comment here explaining why this disable is needed?
There was a problem hiding this comment.
@AlexVelezLl, The disable was added because autofixing these rules converted our XML serialization assertions from native DOM API checks (getAttribute, textContent) to jest-dom matchers, which caused the tests to fail.
These serialization suites build nodes from an XML document (DOMParser(..., 'text/xml')), so the resulting elements are XML elements rather than HTMLElements. As a result, matchers such as toHaveAttribute and toHaveTextContent reject the nodes, whereas the native DOM APIs work correctly for these assertions.
Since these tests validate XML serialization output rather than rendered HTML, I opted to keep the native assertions and disable the corresponding jest-dom preference rules.
Would you prefer a different approach, or would adding a brief comment above the disable explaining this rationale be sufficient?
There was a problem hiding this comment.
You're right — that's a legitimate constraint. matchers like and require s and will reject the XML elements produced by with . Keeping native DOM API assertions here makes sense. A brief comment above the disable explaining this rationale would be sufficient. Withdrawing my finding.
There was a problem hiding this comment.
Yeah, that's perfect 😅. Adding a brief comment about this would be great so that people know why these eslint-disable are needed 👐.
There was a problem hiding this comment.
Understood, deferring to @AlexVelezLl's assessment. Apologies for the noise.
| const parser = new DOMParser(); | ||
| const serializer = new XMLSerializer(); | ||
|
|
||
| function reparse(node) { | ||
| const xml = serializer.serializeToString(node); | ||
| const doc = parser.parseFromString(xml, 'text/xml'); | ||
| if (doc.querySelector('parsererror')) throw new Error(`Re-parsed XML has a parsererror:\n${xml}`); | ||
| return doc.documentElement; | ||
| } | ||
|
|
||
| function makeDeclaration() { | ||
| return new QTIDeclaration({ identifier: 'SCORE', baseType: 'float', cardinality: 'single' }); | ||
| } |
There was a problem hiding this comment.
Given that we are repeating these across several suites, could we just use them from the testUtils module?
| it('parses qti-value children as trimmed strings', () => { | ||
| const xmlNode = parseDefaultValue(` | ||
| <qti-response-declaration identifier="X" base-type="float" cardinality="single"> | ||
| <qti-default-value> | ||
| <qti-value>0.5</qti-value> | ||
| </qti-default-value> | ||
| </qti-response-declaration> | ||
| `); | ||
| const dv = DefaultValue.fromXML(xmlNode, makeDeclaration()); | ||
| expect(dv.get()).toEqual([0.5]); |
There was a problem hiding this comment.
Here, the description is a bit misleading because the children are not a trimmed string, but a coerced float value.
There was a problem hiding this comment.
Here the // --- / // Constructor tests / // --- are duplicating the purpose of the describe block, I think we can remove them
| @@ -0,0 +1,17 @@ | |||
| /** | |||
| * Shared XML parse helper for declaration tests. | |||
| * Mirrors the approach in Kolibri's qtiXmlHelpers.js test utility. | |||
There was a problem hiding this comment.
We can remove this Mirrors the approach in Kolibri's qtiXmlHelpers.js test utilit comment.
…est utilities Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
AlexVelezLl
left a comment
There was a problem hiding this comment.
Excellent work here @Abhishek-Punhani. LGTM! 🚀
Summary
Implemented QTI declaration serialization framework and validation utilities
This PR add:
QTIDeclaration.js— Central class representing aqti-response-declarationorqti-outcome-declarationelement. HandlesfromXML(parse from existing XML),fromPlain(construct from flat JSON), andgetXML(emit a DOM node for assembly). Supports all cardinality and base-type combinations defined in the QTI 3.0 spec.QTISanitizer.js— Defensive validation and XSS mitigation utility. Validates structural attributes (identifier,base-type,cardinality) with fail-fast error semantics, and strips unsafe HTML tags from string content using a DOM-based approach that removes<script>/<style>elements entirely rather than extracting their bodies.assembleItem.js—buildXmlNodehelper that constructs DOM element trees from a plain descriptor object{ tag, attrs, children }, used by all declaration strategies.interactionSchema.js— Registry mapping QTI interaction type names to their descriptor shapes, used to validate and look up interaction metadata.References
closes #5965
AI usage
Used Antigravity for final review and nitpicks.