tessera-learn 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plugin/cli.d.ts +1 -0
- package/dist/plugin/cli.js +18 -0
- package/dist/plugin/cli.js.map +1 -0
- package/dist/plugin/index.js +9 -730
- package/dist/plugin/index.js.map +1 -1
- package/dist/validation-B4UhCY5y.js +911 -0
- package/dist/validation-B4UhCY5y.js.map +1 -0
- package/package.json +4 -2
- package/src/plugin/cli.ts +30 -0
- package/src/plugin/export.ts +12 -1
- package/src/plugin/validation.ts +336 -62
- package/src/runtime/adapters/index.ts +1 -1
- package/src/runtime/adapters/retry.ts +86 -15
- package/src/runtime/adapters/scorm-base.ts +90 -46
- package/src/runtime/adapters/scorm12.ts +36 -11
- package/src/runtime/adapters/scorm2004.ts +129 -26
- package/src/runtime/hooks.svelte.ts +22 -1
- package/src/runtime/interaction-format.ts +83 -48
- package/AGENTS.md +0 -1362
package/src/plugin/validation.ts
CHANGED
|
@@ -64,7 +64,15 @@ export function validateProject(projectRoot: string): ValidationResult {
|
|
|
64
64
|
errors.push(...pageResults.errors);
|
|
65
65
|
warnings.push(...pageResults.warnings);
|
|
66
66
|
|
|
67
|
-
// 4.
|
|
67
|
+
// 4. Contract-bypass checks on project-root shell files
|
|
68
|
+
for (const shellFile of ['layout.svelte', 'quiz.svelte']) {
|
|
69
|
+
const shellPath = resolve(projectRoot, shellFile);
|
|
70
|
+
if (existsSync(shellPath)) {
|
|
71
|
+
validateContractBypass(readSourceFileCached(shellPath), shellFile, errors);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 5. Cross-cutting validations
|
|
68
76
|
if (config) {
|
|
69
77
|
crossValidate(config, pageResults, errors, warnings);
|
|
70
78
|
}
|
|
@@ -498,6 +506,57 @@ interface PagesValidationResult extends ValidationResult {
|
|
|
498
506
|
pages: PageInfo[];
|
|
499
507
|
}
|
|
500
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Validate a single page .svelte file. Used for both section-level (flat) and
|
|
511
|
+
* lesson-level pages — the validation is identical, only the containing
|
|
512
|
+
* directory differs.
|
|
513
|
+
*/
|
|
514
|
+
function validatePageFile(
|
|
515
|
+
filePath: string,
|
|
516
|
+
projectRoot: string,
|
|
517
|
+
assetsDir: string,
|
|
518
|
+
navIndex: number,
|
|
519
|
+
errors: string[],
|
|
520
|
+
warnings: string[],
|
|
521
|
+
assetExistsCache: Map<string, boolean>
|
|
522
|
+
): { page: PageInfo; isQuiz: boolean; isGradedQuiz: boolean } {
|
|
523
|
+
const fileRel = relative(projectRoot, filePath);
|
|
524
|
+
const content = readSourceFileCached(filePath);
|
|
525
|
+
|
|
526
|
+
const pageConfig = validatePageConfig(content, fileRel, errors);
|
|
527
|
+
|
|
528
|
+
const isQuiz = !!pageConfig?.quiz;
|
|
529
|
+
let isGradedQuiz = false;
|
|
530
|
+
if (pageConfig?.quiz) {
|
|
531
|
+
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
532
|
+
if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
|
|
533
|
+
isGradedQuiz = true;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
538
|
+
|
|
539
|
+
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
540
|
+
validateQuestionComponents(content, fileRel, errors);
|
|
541
|
+
validateContractBypass(content, fileRel, errors);
|
|
542
|
+
if (
|
|
543
|
+
pageConfig?.quiz &&
|
|
544
|
+
!HAS_USE_QUESTION_RE.test(content) &&
|
|
545
|
+
!HAS_QUESTION_TAG_RE.test(content)
|
|
546
|
+
) {
|
|
547
|
+
warnings.push(
|
|
548
|
+
`${fileRel}: quiz page has no question components or useQuestion() calls — ` +
|
|
549
|
+
`the quiz will have nothing to score`
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
page: { fileRel, navIndex, hasGradedQuiz: isGradedQuiz, hasQuiz: isQuiz, completesOnView },
|
|
555
|
+
isQuiz,
|
|
556
|
+
isGradedQuiz,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
501
560
|
function validatePages(
|
|
502
561
|
pagesDir: string,
|
|
503
562
|
assetsDir: string,
|
|
@@ -581,34 +640,19 @@ function validatePages(
|
|
|
581
640
|
}
|
|
582
641
|
|
|
583
642
|
for (const fileName of sectionSvelteFiles) {
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
643
|
+
const result = validatePageFile(
|
|
644
|
+
resolve(sectionPath, fileName),
|
|
645
|
+
projectRoot,
|
|
646
|
+
assetsDir,
|
|
647
|
+
totalPages,
|
|
648
|
+
errors,
|
|
649
|
+
warnings,
|
|
650
|
+
assetExistsCache
|
|
651
|
+
);
|
|
590
652
|
totalPages++;
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
totalQuizzes++;
|
|
595
|
-
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
596
|
-
if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
|
|
597
|
-
hasGradedQuiz = true;
|
|
598
|
-
pageHasGradedQuiz = true;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
603
|
-
pages.push({
|
|
604
|
-
fileRel,
|
|
605
|
-
navIndex,
|
|
606
|
-
hasGradedQuiz: pageHasGradedQuiz,
|
|
607
|
-
hasQuiz: !!pageConfig?.quiz,
|
|
608
|
-
completesOnView,
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
653
|
+
if (result.isQuiz) totalQuizzes++;
|
|
654
|
+
if (result.isGradedQuiz) hasGradedQuiz = true;
|
|
655
|
+
pages.push(result.page);
|
|
612
656
|
}
|
|
613
657
|
|
|
614
658
|
// Get lesson directories
|
|
@@ -663,38 +707,19 @@ function validatePages(
|
|
|
663
707
|
|
|
664
708
|
// Validate each .svelte file
|
|
665
709
|
for (const fileName of svelteFiles) {
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
710
|
+
const result = validatePageFile(
|
|
711
|
+
resolve(lessonPath, fileName),
|
|
712
|
+
projectRoot,
|
|
713
|
+
assetsDir,
|
|
714
|
+
totalPages,
|
|
715
|
+
errors,
|
|
716
|
+
warnings,
|
|
717
|
+
assetExistsCache
|
|
718
|
+
);
|
|
672
719
|
totalPages++;
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
totalQuizzes++;
|
|
677
|
-
|
|
678
|
-
// Validate quiz config
|
|
679
|
-
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
680
|
-
|
|
681
|
-
if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
|
|
682
|
-
hasGradedQuiz = true;
|
|
683
|
-
pageHasGradedQuiz = true;
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
688
|
-
pages.push({
|
|
689
|
-
fileRel,
|
|
690
|
-
navIndex,
|
|
691
|
-
hasGradedQuiz: pageHasGradedQuiz,
|
|
692
|
-
hasQuiz: !!pageConfig?.quiz,
|
|
693
|
-
completesOnView,
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
// Check $assets references
|
|
697
|
-
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
720
|
+
if (result.isQuiz) totalQuizzes++;
|
|
721
|
+
if (result.isGradedQuiz) hasGradedQuiz = true;
|
|
722
|
+
pages.push(result.page);
|
|
698
723
|
}
|
|
699
724
|
}
|
|
700
725
|
}
|
|
@@ -774,7 +799,7 @@ function validateCompletesOn(
|
|
|
774
799
|
|
|
775
800
|
function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): void {
|
|
776
801
|
if (!quiz || typeof quiz !== 'object') return;
|
|
777
|
-
const cfg = quiz as
|
|
802
|
+
const cfg = quiz as Record<string, unknown>;
|
|
778
803
|
|
|
779
804
|
if (cfg.maxAttempts !== undefined) {
|
|
780
805
|
const val = cfg.maxAttempts;
|
|
@@ -785,9 +810,258 @@ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): v
|
|
|
785
810
|
}
|
|
786
811
|
}
|
|
787
812
|
|
|
788
|
-
|
|
813
|
+
for (const field of ['graded', 'gatesProgress', 'showFeedback']) {
|
|
814
|
+
if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
|
|
815
|
+
errors.push(
|
|
816
|
+
`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ---------- Question Component Validation ----------
|
|
823
|
+
|
|
824
|
+
const QUESTION_COMPONENT_REQUIRED: Record<string, string[]> = {
|
|
825
|
+
MultipleChoice: ['question', 'options', 'correct'],
|
|
826
|
+
FillInTheBlank: ['question', 'answers'],
|
|
827
|
+
Matching: ['question', 'pairs'],
|
|
828
|
+
Sorting: ['question', 'items', 'targets', 'correct'],
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
type PropValue =
|
|
832
|
+
| { kind: 'string'; value: string }
|
|
833
|
+
| { kind: 'expr'; raw: string }
|
|
834
|
+
| { kind: 'bool' };
|
|
835
|
+
|
|
836
|
+
/** Extract a balanced {...} or [...] span starting at startIndex, or null. */
|
|
837
|
+
function extractBalanced(source: string, startIndex: number): string | null {
|
|
838
|
+
const open = source[startIndex];
|
|
839
|
+
if (open !== '{' && open !== '[') return null;
|
|
840
|
+
let depth = 0;
|
|
841
|
+
let inString: string | null = null;
|
|
842
|
+
let escaped = false;
|
|
843
|
+
for (let i = startIndex; i < source.length; i++) {
|
|
844
|
+
const char = source[i];
|
|
845
|
+
if (escaped) {
|
|
846
|
+
escaped = false;
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
if (char === '\\' && inString) {
|
|
850
|
+
escaped = true;
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (inString) {
|
|
854
|
+
if (char === inString) inString = null;
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
858
|
+
inString = char;
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
if (char === '{' || char === '[') depth++;
|
|
862
|
+
if (char === '}' || char === ']') {
|
|
863
|
+
depth--;
|
|
864
|
+
if (depth === 0) return source.slice(startIndex, i + 1);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Parse the props of an opening tag starting just after the component name.
|
|
872
|
+
* Returns null if the tag can't be parsed cleanly — callers then skip it
|
|
873
|
+
* rather than risk a false positive.
|
|
874
|
+
*/
|
|
875
|
+
function parseTagProps(content: string, start: number): Map<string, PropValue> | null {
|
|
876
|
+
const props = new Map<string, PropValue>();
|
|
877
|
+
let i = start;
|
|
878
|
+
while (i < content.length) {
|
|
879
|
+
while (i < content.length && /\s/.test(content[i])) i++;
|
|
880
|
+
if (i >= content.length) return null;
|
|
881
|
+
const c = content[i];
|
|
882
|
+
if (c === '>') return props;
|
|
883
|
+
if (c === '/' && content[i + 1] === '>') return props;
|
|
884
|
+
// Spread / shorthand expression — skip the whole {...} block.
|
|
885
|
+
if (c === '{') {
|
|
886
|
+
const block = extractBalanced(content, i);
|
|
887
|
+
if (!block) return null;
|
|
888
|
+
i += block.length;
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
const nameMatch = /^[A-Za-z_][\w-]*/.exec(content.slice(i));
|
|
892
|
+
if (!nameMatch) return null;
|
|
893
|
+
const propName = nameMatch[0];
|
|
894
|
+
i += propName.length;
|
|
895
|
+
while (i < content.length && /\s/.test(content[i])) i++;
|
|
896
|
+
if (content[i] !== '=') {
|
|
897
|
+
props.set(propName, { kind: 'bool' });
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
i++;
|
|
901
|
+
while (i < content.length && /\s/.test(content[i])) i++;
|
|
902
|
+
const v = content[i];
|
|
903
|
+
if (v === '"' || v === "'") {
|
|
904
|
+
const end = content.indexOf(v, i + 1);
|
|
905
|
+
if (end === -1) return null;
|
|
906
|
+
props.set(propName, { kind: 'string', value: content.slice(i + 1, end) });
|
|
907
|
+
i = end + 1;
|
|
908
|
+
} else if (v === '{') {
|
|
909
|
+
const block = extractBalanced(content, i);
|
|
910
|
+
if (!block) return null;
|
|
911
|
+
props.set(propName, { kind: 'expr', raw: block.slice(1, -1).trim() });
|
|
912
|
+
i += block.length;
|
|
913
|
+
} else {
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function staticArray(prop: PropValue | undefined): unknown[] | null {
|
|
921
|
+
if (prop?.kind !== 'expr' || !prop.raw.startsWith('[')) return null;
|
|
922
|
+
try {
|
|
923
|
+
const parsed = JSON5.parse(prop.raw);
|
|
924
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
925
|
+
} catch {
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function staticNumber(prop: PropValue | undefined): number | null {
|
|
931
|
+
if (prop?.kind !== 'expr') return null;
|
|
932
|
+
try {
|
|
933
|
+
const parsed = JSON5.parse(prop.raw);
|
|
934
|
+
return typeof parsed === 'number' ? parsed : null;
|
|
935
|
+
} catch {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function validateQuestionComponents(
|
|
941
|
+
content: string,
|
|
942
|
+
fileRel: string,
|
|
943
|
+
errors: string[]
|
|
944
|
+
): void {
|
|
945
|
+
const names = Object.keys(QUESTION_COMPONENT_REQUIRED).join('|');
|
|
946
|
+
const tagStartRe = new RegExp(`<(${names})(?=[\\s/>])`, 'g');
|
|
947
|
+
const seenIds = new Set<string>();
|
|
948
|
+
let m: RegExpExecArray | null;
|
|
949
|
+
while ((m = tagStartRe.exec(content)) !== null) {
|
|
950
|
+
const name = m[1];
|
|
951
|
+
const props = parseTagProps(content, m.index + m[0].length);
|
|
952
|
+
if (!props) continue;
|
|
953
|
+
|
|
954
|
+
for (const req of QUESTION_COMPONENT_REQUIRED[name]) {
|
|
955
|
+
if (!props.has(req)) {
|
|
956
|
+
errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const idProp = props.get('id');
|
|
961
|
+
if (idProp?.kind === 'string') {
|
|
962
|
+
if (seenIds.has(idProp.value)) {
|
|
963
|
+
errors.push(
|
|
964
|
+
`${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id`
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
seenIds.add(idProp.value);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (name === 'MultipleChoice') {
|
|
971
|
+
const options = staticArray(props.get('options'));
|
|
972
|
+
const correct = staticNumber(props.get('correct'));
|
|
973
|
+
if (options && correct !== null) {
|
|
974
|
+
if (!Number.isInteger(correct) || correct < 0 || correct >= options.length) {
|
|
975
|
+
errors.push(
|
|
976
|
+
`${fileRel}: <MultipleChoice> correct={${correct}} is out of range for ${options.length} options (valid: 0–${options.length - 1})`
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
} else if (name === 'Sorting') {
|
|
981
|
+
const items = staticArray(props.get('items'));
|
|
982
|
+
const targets = staticArray(props.get('targets'));
|
|
983
|
+
const correct = staticArray(props.get('correct'));
|
|
984
|
+
if (items && correct && correct.length !== items.length) {
|
|
985
|
+
errors.push(
|
|
986
|
+
`${fileRel}: <Sorting> correct has ${correct.length} entries but items has ${items.length} — they must be parallel arrays`
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
if (targets && correct) {
|
|
990
|
+
for (const idx of correct) {
|
|
991
|
+
if (
|
|
992
|
+
typeof idx !== 'number' ||
|
|
993
|
+
!Number.isInteger(idx) ||
|
|
994
|
+
idx < 0 ||
|
|
995
|
+
idx >= targets.length
|
|
996
|
+
) {
|
|
997
|
+
errors.push(
|
|
998
|
+
`${fileRel}: <Sorting> correct contains ${JSON.stringify(idx)}, out of range for ${targets.length} targets (valid: 0–${targets.length - 1})`
|
|
999
|
+
);
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
} else if (name === 'Matching') {
|
|
1005
|
+
const pairs = staticArray(props.get('pairs'));
|
|
1006
|
+
if (pairs) {
|
|
1007
|
+
const bad = pairs.some(
|
|
1008
|
+
(p) =>
|
|
1009
|
+
typeof p !== 'object' ||
|
|
1010
|
+
p === null ||
|
|
1011
|
+
typeof (p as { left?: unknown }).left !== 'string' ||
|
|
1012
|
+
typeof (p as { right?: unknown }).right !== 'string'
|
|
1013
|
+
);
|
|
1014
|
+
if (bad) {
|
|
1015
|
+
errors.push(
|
|
1016
|
+
`${fileRel}: <Matching> pairs must be an array of { left: string, right: string } objects`
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
} else if (name === 'FillInTheBlank') {
|
|
1021
|
+
const answers = staticArray(props.get('answers'));
|
|
1022
|
+
if (answers) {
|
|
1023
|
+
if (answers.length === 0) {
|
|
1024
|
+
errors.push(`${fileRel}: <FillInTheBlank> answers must not be empty`);
|
|
1025
|
+
} else if (answers.some((a) => typeof a !== 'string')) {
|
|
1026
|
+
errors.push(
|
|
1027
|
+
`${fileRel}: <FillInTheBlank> answers must be an array of strings`
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// ---------- Contract Bypass Detection ----------
|
|
1036
|
+
|
|
1037
|
+
const QUIZ_COMPLETE_DISPATCH_RE =
|
|
1038
|
+
/(?:new\s+CustomEvent\s*\(\s*['"]tessera-quiz-complete['"]|dispatchEvent\s*\([\s\S]{0,120}tessera-quiz-complete)/;
|
|
1039
|
+
const RUNTIME_INTERNAL_IMPORT_RE = /from\s+['"]tessera-learn\/runtime\//;
|
|
1040
|
+
const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
|
|
1041
|
+
const HAS_QUESTION_TAG_RE = new RegExp(
|
|
1042
|
+
`<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`
|
|
1043
|
+
);
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Detect ways an author file can bypass the LMS data contract. These check
|
|
1047
|
+
* source text for known escape hatches — they never inspect course content,
|
|
1048
|
+
* so they constrain how you wire things up, not what you build.
|
|
1049
|
+
*/
|
|
1050
|
+
function validateContractBypass(
|
|
1051
|
+
content: string,
|
|
1052
|
+
fileRel: string,
|
|
1053
|
+
errors: string[]
|
|
1054
|
+
): void {
|
|
1055
|
+
if (QUIZ_COMPLETE_DISPATCH_RE.test(content)) {
|
|
1056
|
+
errors.push(
|
|
1057
|
+
`${fileRel}: dispatches "tessera-quiz-complete" directly — submit through ` +
|
|
1058
|
+
`useQuiz().submit() so the result reaches the LMS`
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
if (RUNTIME_INTERNAL_IMPORT_RE.test(content)) {
|
|
789
1062
|
errors.push(
|
|
790
|
-
`${fileRel}:
|
|
1063
|
+
`${fileRel}: imports from tessera-learn/runtime/* — use the public hooks ` +
|
|
1064
|
+
`(useQuiz, useQuestion, useNavigation, …) instead`
|
|
791
1065
|
);
|
|
792
1066
|
}
|
|
793
1067
|
}
|
|
@@ -36,7 +36,7 @@ function missingApiError(
|
|
|
36
36
|
standard,
|
|
37
37
|
`Tessera: this course is configured for ${label} but ${detail} ` +
|
|
38
38
|
`The course must be launched from an LMS that provides the ${label} runtime. ` +
|
|
39
|
-
`If you are testing locally, run \`npm run
|
|
39
|
+
`If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Surfaces LMSGetLastError / LMSGetErrorString / LMSGetDiagnostic so failure
|
|
3
|
+
* logs can name the cause instead of a generic "LMS call failed". SCORM
|
|
4
|
+
* Cloud uses the diagnostic to name the offending data-model element.
|
|
5
5
|
*/
|
|
6
6
|
export interface LMSErrorReporter {
|
|
7
|
-
/** Last error from `LMSGetLastError` / `GetLastError`. */
|
|
8
7
|
code(): string;
|
|
9
|
-
/** Human-readable message from `LMSGetErrorString` / `GetErrorString`. */
|
|
10
8
|
message(code: string): string;
|
|
9
|
+
diagnostic?(code: string): string;
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
/** Default attempt count for LMS retry loops (one initial + two retries). */
|
|
@@ -33,19 +32,53 @@ function logRetryGiveUp(
|
|
|
33
32
|
lastErrCode: string,
|
|
34
33
|
context: string | undefined
|
|
35
34
|
): void {
|
|
36
|
-
let detail = '';
|
|
37
|
-
if (errorReporter && lastErrCode && lastErrCode !== '0') {
|
|
38
|
-
try {
|
|
39
|
-
const msg = errorReporter.message(lastErrCode);
|
|
40
|
-
detail = ` (LMS error ${lastErrCode}${msg ? `: ${msg}` : ''})`;
|
|
41
|
-
} catch {}
|
|
42
|
-
}
|
|
43
35
|
const ctx = context ? ` [${context}]` : '';
|
|
44
36
|
console.warn(
|
|
45
|
-
`Tessera: LMS call failed after retries${ctx}${
|
|
37
|
+
`Tessera: LMS call failed after retries${ctx}${formatLMSErrorDetail(errorReporter, lastErrCode)}, continuing without persistence`
|
|
46
38
|
);
|
|
47
39
|
}
|
|
48
40
|
|
|
41
|
+
export function formatLMSErrorDetail(
|
|
42
|
+
errorReporter: LMSErrorReporter | undefined,
|
|
43
|
+
code: string
|
|
44
|
+
): string {
|
|
45
|
+
if (!errorReporter || !code || code === '0') return '';
|
|
46
|
+
let msg = '';
|
|
47
|
+
let diag = '';
|
|
48
|
+
try { msg = errorReporter.message(code); } catch {}
|
|
49
|
+
try { diag = errorReporter.diagnostic?.(code) ?? ''; } catch {}
|
|
50
|
+
let detail = ` (LMS error ${code}`;
|
|
51
|
+
if (msg) detail += `: ${msg}`;
|
|
52
|
+
if (diag && diag !== msg) detail += ` — ${diag}`;
|
|
53
|
+
detail += ')';
|
|
54
|
+
return detail;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Sync call that warns with the LMS error code on failure (terminate-path). */
|
|
58
|
+
export function callSyncOrWarn(
|
|
59
|
+
fn: () => any,
|
|
60
|
+
context: string,
|
|
61
|
+
errorReporter?: LMSErrorReporter
|
|
62
|
+
): boolean {
|
|
63
|
+
let ok = false;
|
|
64
|
+
try {
|
|
65
|
+
ok = lmsCallSucceeded(fn());
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.warn(
|
|
68
|
+
`Tessera: LMS call threw [${context}] during terminate`,
|
|
69
|
+
err
|
|
70
|
+
);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (!ok) {
|
|
74
|
+
const code = readLastErrorCode(errorReporter);
|
|
75
|
+
console.warn(
|
|
76
|
+
`Tessera: LMS call failed [${context}] during terminate${formatLMSErrorDetail(errorReporter, code)}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return ok;
|
|
80
|
+
}
|
|
81
|
+
|
|
49
82
|
/**
|
|
50
83
|
* Retry wrapper for LMS API calls.
|
|
51
84
|
* Retries up to maxRetries times with exponential backoff.
|
|
@@ -68,17 +101,25 @@ export async function withRetry(
|
|
|
68
101
|
context?: string
|
|
69
102
|
): Promise<boolean> {
|
|
70
103
|
let lastErrCode = '';
|
|
104
|
+
let threw = false;
|
|
105
|
+
let lastError: unknown;
|
|
71
106
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
107
|
+
threw = false;
|
|
72
108
|
try {
|
|
73
109
|
if (lmsCallSucceeded(fn())) return true;
|
|
74
|
-
} catch {
|
|
75
|
-
|
|
110
|
+
} catch (err) {
|
|
111
|
+
threw = true;
|
|
112
|
+
lastError = err;
|
|
76
113
|
}
|
|
77
114
|
lastErrCode = readLastErrorCode(errorReporter);
|
|
78
115
|
if (attempt < maxRetries - 1) {
|
|
79
116
|
await new Promise((r) => setTimeout(r, backoffMs(attempt)));
|
|
80
117
|
}
|
|
81
118
|
}
|
|
119
|
+
if (threw) {
|
|
120
|
+
const ctx = context ? ` [${context}]` : '';
|
|
121
|
+
console.warn(`Tessera: LMS call threw${ctx} on final retry`, lastError);
|
|
122
|
+
}
|
|
82
123
|
logRetryGiveUp(errorReporter, lastErrCode, context);
|
|
83
124
|
return false;
|
|
84
125
|
}
|
|
@@ -275,6 +316,36 @@ export function formatHHMMSS(totalSeconds: number): string {
|
|
|
275
316
|
return `${hh}:${mm}:${ss}.00`;
|
|
276
317
|
}
|
|
277
318
|
|
|
319
|
+
/**
|
|
320
|
+
* SCORM 2004 4E §4.2/§4.3 define CMIDecimal-like elements as real(10,7) —
|
|
321
|
+
* `String(1/3)` exceeds that and trips SCORM Cloud with error 406. Rounds,
|
|
322
|
+
* then trims trailing zeros (no padded "0.8500000" forms).
|
|
323
|
+
*/
|
|
324
|
+
export function formatReal107(value: number): string {
|
|
325
|
+
if (!Number.isFinite(value)) return '0';
|
|
326
|
+
const rounded = Math.round(value * 1e7) / 1e7;
|
|
327
|
+
return rounded
|
|
328
|
+
.toFixed(7)
|
|
329
|
+
.replace(/(\.\d*?)0+$/, '$1')
|
|
330
|
+
.replace(/\.$/, '');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* SCORM 2004 4E §3.3.10.1 references ISO 8601 §5.3.3 — local date+time, no
|
|
335
|
+
* zone designator. Strict validators reject `Z`, `±hh:mm`, and fractional
|
|
336
|
+
* seconds with error 406. UTC components are used so writes don't drift
|
|
337
|
+
* across local-TZ flips even though the format is zone-free.
|
|
338
|
+
*/
|
|
339
|
+
export function formatISO8601Timestamp(date: Date): string {
|
|
340
|
+
const yyyy = date.getUTCFullYear();
|
|
341
|
+
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
342
|
+
const dd = String(date.getUTCDate()).padStart(2, '0');
|
|
343
|
+
const hh = String(date.getUTCHours()).padStart(2, '0');
|
|
344
|
+
const mi = String(date.getUTCMinutes()).padStart(2, '0');
|
|
345
|
+
const ss = String(date.getUTCSeconds()).padStart(2, '0');
|
|
346
|
+
return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
278
349
|
/**
|
|
279
350
|
* Format seconds as ISO 8601 duration: PT1H30M45S
|
|
280
351
|
*/
|