├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── imsmanifest.xml ├── index.html ├── package.json ├── src ├── HTMLGenerator.ts ├── ManifestGenerator.ts ├── MessageHandler.ts ├── SCORMAdapter.test.ts ├── SCORMAdapter.ts ├── hashString.test.ts ├── hashString.ts ├── index.ts ├── loadContent.js └── loadContent.test.ts ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const isDev = process.env.NODE_ENV !== "production"; 2 | 3 | module.exports = { 4 | env: { browser: true, es6: true, node: true }, 5 | extends: [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | ], 10 | globals: { 11 | Atomics: "readonly", 12 | SharedArrayBuffer: "readonly", 13 | }, 14 | parser: "@typescript-eslint/parser", 15 | parserOptions: { 16 | ecmaFeatures: { 17 | jsx: true, 18 | }, 19 | ecmaVersion: 2018, 20 | sourceType: "module", 21 | }, 22 | plugins: ["@typescript-eslint"], 23 | rules: { 24 | "@typescript-eslint/camelcase": "off", 25 | "@typescript-eslint/explicit-function-return-type": "off", 26 | "@typescript-eslint/explicit-member-accessibility": "off", 27 | "@typescript-eslint/indent": ["warn", 2], 28 | "@typescript-eslint/member-delimiter-style": "warn", 29 | "@typescript-eslint/no-explicit-any": "warn", 30 | "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], 31 | "@typescript-eslint/no-var-requires": "off", 32 | "@typescript-eslint/type-annotation-spacing": "warn", 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Didask 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SCOL-R 2 | 3 | SCOL-R (Shareable Cross Origin Learning Resources) helps you connect pedagogical content with an LMS through the SCORM API even when the two are on different domains. 4 | 5 | To get started: 6 | 7 | - Set the remote content's URL in the `body`'s `data-source` attribute in [index.html](index.html#L13). 8 | - Edit [imsmanifest.xml](imsmanifest.xml) to edit the course title and identifier, as well as the metadata. 9 | - Compile everything (except the README file) in a zip file and upload it to your LMS. 10 | 11 | The SCORM Adapter intends to be compatible with SCORM versions 1.2 and 2004, hopefully with more to come. 12 | The version should be specified in the manifest's `schemaversion` metadata attribute. 13 | 14 | ---- 15 | v1.0.0 : 16 | 💣 Breaking changes : 17 | 18 | We remove LMSCommit after SetValue, now you have to handle it from your side for offline SCORM. 19 | 20 | We put LMSCommit as debounce (500ms) after each setValue 21 | 22 | ---- 23 | 24 | Made with ❤️ by [Didask](https://www.didask.com/) 25 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /imsmanifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ADL SCORM 5 | 1.2 6 | 7 | 8 | [[course-identifier]] 9 | 10 | 11 | 12 | 13 | 14 | LOMv1.0 15 | 16 | 17 | Author 18 | 19 | 20 | 21 | begin:vcard 22 | fn:[[course-author]] 23 | end:vcard 24 | 25 | 26 | 27 | 28 | 29 | [[course-typical-learning-time]] 30 | 31 | 32 | 33 | 34 | 35 | 36 | [[course-title]] 37 | 38 | [[course-title]] 39 | 40 | 41 | 42 | [[course-identifier]] 43 | 44 | 45 | 46 | 47 | 48 | LOMv1.0 49 | 50 | 51 | Author 52 | 53 | 54 | 55 | begin:vcard 56 | fn:[[course-author]] 57 | end:vcard 58 | 59 | 60 | 61 | 62 | 63 | [[course-typical-learning-time]] 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SCO local endpoint 5 | 6 | 9 | 10 | 11 | 12 | 91 | 92 | 93 | 94 |
95 |
96 |
97 |

Your content is loading...

98 |

99 | Please wait, or if your content doesn't appear, try closing and 100 | opening this window again. 101 |

102 |
103 |
104 |
105 |

106 | If the initialization fails, error messages will appear below: 107 |

108 |
109 | 117 |
118 |
119 | 120 | 121 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@didask/scol-r", 3 | "version": "2.12.0", 4 | "description": "Shareable Cross-Origin Learning Resources", 5 | "main": "index.html", 6 | "types": "lib/index.d.ts", 7 | "files": [ 8 | "lib/" 9 | ], 10 | "scripts": { 11 | "build": "tsc", 12 | "clean": "rm -rf node_modules lib", 13 | "test": "jest" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/Didask/scol-r.git" 18 | }, 19 | "keywords": [ 20 | "SCORM", 21 | "CORS" 22 | ], 23 | "author": "Didask", 24 | "license": "ISC", 25 | "bugs": { 26 | "url": "https://github.com/Didask/scol-r/issues" 27 | }, 28 | "homepage": "https://github.com/Didask/scol-r", 29 | "devDependencies": { 30 | "@babel/core": "7.23.9", 31 | "@babel/preset-env": "7.23.9", 32 | "@babel/preset-typescript": "7.23.3", 33 | "@types/jest": "29.5.12", 34 | "babel-jest": "29.7.0", 35 | "husky": "3.1.0", 36 | "jest": "29.7.0", 37 | "jest-environment-jsdom": "29.7.0", 38 | "typescript": "5.3.3" 39 | }, 40 | "husky": { 41 | "hooks": { 42 | "pre-commit": "jest && tsc" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/HTMLGenerator.ts: -------------------------------------------------------------------------------- 1 | export interface HTMLGeneratorProps { 2 | dataSource: string; 3 | libPath?: string; 4 | hashIdentifiers?: boolean; 5 | } 6 | 7 | export function HTMLGenerator(props: HTMLGeneratorProps) { 8 | const { dataSource, libPath = "lib", hashIdentifiers = false } = props; 9 | return ` 10 | 11 | 12 | SCO local endpoint 13 | 14 | 15 | 16 | 17 | 18 | 19 | 53 | 54 | 55 | 56 |
57 |
58 |

Your content is loading...

59 |

Please wait, or if your content doesn't appear, try closing and opening this window again.

60 |
61 |
62 |

If the initialization fails, error messages will appear below:

63 |
64 | 67 |
68 |
69 | 70 | `; 71 | } 72 | -------------------------------------------------------------------------------- /src/ManifestGenerator.ts: -------------------------------------------------------------------------------- 1 | import { scormVersions } from "."; 2 | 3 | export class Sco { 4 | scoID: string; 5 | scoTitle: string; 6 | scoHref: `./${string}`; 7 | author: string; 8 | learningTime: number; 9 | resources: string[]; 10 | 11 | constructor(props: { 12 | scoID: string; 13 | scoTitle: string; 14 | author: string; 15 | learningTime: number; 16 | scoHref: `./${string}`; 17 | resources: string[]; 18 | }) { 19 | this.scoID = props.scoID; 20 | this.scoTitle = props.scoTitle; 21 | this.author = props.author; 22 | this.learningTime = props.learningTime; 23 | this.resources = props.resources; 24 | this.scoHref = props.scoHref; 25 | } 26 | } 27 | 28 | const formatLearningTime = (learningTime: number) => { 29 | const intHours = Math.floor(learningTime / 60); 30 | const hours = intHours > 10 ? intHours : `0${intHours}`; 31 | const intMinutes = intHours > 0 ? learningTime - intHours * 60 : learningTime; 32 | const minutes = intMinutes > 10 ? intMinutes : `0${intMinutes}`; 33 | return `${hours}:${minutes}:00`; 34 | }; 35 | 36 | export interface ManifestGeneratorProps { 37 | courseId: string; 38 | courseTitle: string; 39 | courseAuthor: string; 40 | scoList?: Sco[]; 41 | sharedResources?: string[]; 42 | totalLearningTime?: number; 43 | dataFromLms?: string; 44 | scormVersion?: (typeof scormVersions)[number]; 45 | } 46 | 47 | const removeSpecialChars = (obj: T): T => 48 | Object.entries(obj).reduce( 49 | (acc, [key, value]) => ({ 50 | ...acc, 51 | [key]: value.replace(/&/g, "-"), 52 | }), 53 | {} as T 54 | ); 55 | 56 | export function ManifestGenerator({ 57 | courseId, 58 | scoList = [], 59 | sharedResources = [], 60 | totalLearningTime = 0, 61 | dataFromLms, 62 | scormVersion = "1.2", 63 | ...props 64 | }: ManifestGeneratorProps) { 65 | const { courseTitle, courseAuthor } = 66 | removeSpecialChars>(props); 67 | const courseGlobalLearningTime = scoList.length 68 | ? scoList.reduce((acc, sco) => acc + sco.learningTime, 0) 69 | : totalLearningTime; 70 | 71 | return ` 72 | 73 | 74 | ADL SCORM 75 | ${scormVersion} 76 | 77 | 78 | ${courseId} 79 | 80 | ${courseTitle} 81 | 82 | 83 | 84 | 85 | 86 | 87 | LOMv1.0 88 | 89 | 90 | Author 91 | 92 | 93 | 94 | 95 | begin:vcard 96 | fn:${courseAuthor} 97 | end:vcard 98 | 99 | 100 | 101 | 102 | 103 | ADL SCORM 1.2 104 | 105 | 106 | 107 | 108 | LOMv1.0 109 | 110 | 111 | yes 112 | 113 | 114 | 115 | 116 | LOMv1.0 117 | 118 | 119 | yes 120 | 121 | 122 | 123 | 124 | 125 | ${formatLearningTime( 126 | courseGlobalLearningTime 127 | )} 128 | 129 | 130 | 131 | 132 | 133 | 134 | ${courseTitle} 135 | ${scoList 136 | .map(({ scoID, learningTime, resources, ...props }) => { 137 | const { scoTitle, author } = 138 | removeSpecialChars>(props); 139 | return ` 140 | ${scoTitle} 141 | ${ 142 | dataFromLms ?? courseId + ":" + scoID 143 | } 144 | 145 | 146 | 147 | ${scoID} 148 | 149 | 150 | 151 | 152 | 153 | LOMv1.0 154 | 155 | 156 | Author 157 | 158 | 159 | 160 | 161 | begin:vcard 162 | fn:${author} 163 | end:vcard 164 | 165 | 166 | 167 | 168 | 169 | 170 | ${formatLearningTime( 171 | learningTime 172 | )} 173 | 174 | 175 | 176 | 177 | `; 178 | }) 179 | .join("\n")} 180 | 181 | 182 | 183 | ${ 184 | sharedResources?.length 185 | ? ` 186 | ${sharedResources 187 | .map((resource) => ``) 188 | .join("\n")} 189 | ` 190 | : "" 191 | } 192 | ${scoList 193 | .map((sco) => { 194 | return ` 197 | ${ 198 | sharedResources?.length 199 | ? '' 200 | : "" 201 | } 202 | ${sco.resources 203 | .map((resource) => ``) 204 | .join("\n")} 205 | `; 206 | }) 207 | .join("\n")} 208 | 209 | `; 210 | } 211 | -------------------------------------------------------------------------------- /src/MessageHandler.ts: -------------------------------------------------------------------------------- 1 | export class MessageReceiver { 2 | constructor( 3 | win: Window, 4 | sourceOrigin: string, 5 | private readonly adapter: any, 6 | ) { 7 | let timeoutId: NodeJS.Timeout | null = null; 8 | 9 | win.addEventListener( 10 | "message", 11 | function (this: MessageReceiver, e: MessageEvent) { 12 | if (e.origin !== sourceOrigin) return; 13 | const functionName = e.data["function"]; 14 | const functionArgs = e.data["arguments"]; 15 | if ( 16 | functionName && 17 | functionArgs && 18 | typeof this[functionName as keyof MessageReceiver] === "function" 19 | ) { 20 | // @ts-ignore 21 | this[functionName].apply(this, functionArgs); 22 | if (timeoutId) { 23 | clearTimeout(timeoutId); 24 | } 25 | timeoutId = setTimeout(() => { 26 | this.commit(); 27 | timeoutId = null; 28 | }, 500); 29 | } 30 | }.bind(this), 31 | ); 32 | } 33 | 34 | commit() { 35 | this.adapter.LMSCommit(); 36 | } 37 | 38 | setTitle(title: string) { 39 | document.title = title; 40 | } 41 | 42 | setScore(score: string) { 43 | this.adapter.setScore(score); 44 | } 45 | 46 | setStudent(studentId: string, studentName: string) { 47 | this.adapter.setStudent(studentId, studentName); 48 | } 49 | 50 | setLessonStatus(lessonStatus: string) { 51 | this.adapter.setLessonStatus(lessonStatus); 52 | this.adapter.LMSCommit(); // We commit the changes to the LMS each time the lesson status is updated. 53 | } 54 | 55 | setObjectives(objectivesIds: string[]) { 56 | if (this.adapter.objectivesAreAvailable) { 57 | this.adapter.setObjectives(objectivesIds); 58 | } 59 | } 60 | 61 | setObjectiveScore(objectiveId: string, score: number) { 62 | if (this.adapter.objectivesAreAvailable) { 63 | this.adapter.setObjectiveScore(objectiveId, score); 64 | } 65 | } 66 | 67 | setObjectiveStatus(objectiveId: string, status: string) { 68 | if (this.adapter.objectivesAreAvailable) { 69 | this.adapter.setObjectiveStatus(objectiveId, status); 70 | } 71 | } 72 | } 73 | 74 | export class MessageEmitter { 75 | private currentWindow: Window; 76 | private lmsOrigin: string; 77 | 78 | constructor(lmsOrigin: string) { 79 | this.currentWindow = window.parent || window.opener; 80 | this.lmsOrigin = lmsOrigin; 81 | } 82 | 83 | private sendMessage( 84 | name: string, 85 | values: (string[] | string | number)[], 86 | ): void { 87 | this.currentWindow.postMessage( 88 | { 89 | function: name, 90 | arguments: values, 91 | }, 92 | this.lmsOrigin, 93 | ); 94 | } 95 | 96 | setStudent({ id, name }: { id: string; name: string }): void { 97 | this.sendMessage("setStudent", [id, name]); 98 | } 99 | setLessonStatus(status: string): void { 100 | this.sendMessage("setLessonStatus", [status]); 101 | } 102 | setScore(score: number): void { 103 | this.sendMessage("setScore", [score]); 104 | } 105 | setObjectives(objectives: string[]): void { 106 | this.sendMessage("setObjectives", [objectives]); 107 | } 108 | setObjectiveScore(objectiveId: string, score: number): void { 109 | this.sendMessage("setObjectiveScore", [objectiveId, score]); 110 | } 111 | setObjectiveStatus( 112 | objectiveId: string, 113 | status: "completed" | "incomplete", 114 | ): void { 115 | this.sendMessage("setObjectiveStatus", [objectiveId, status]); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/SCORMAdapter.test.ts: -------------------------------------------------------------------------------- 1 | import { convertMsToCMITimespan, convertToTimeInterval } from "./SCORMAdapter"; 2 | 3 | test('convertMsToCMITimespan ("0000:00:00.00")', () => { 4 | const milliseconds = 36 * 60 * 60 * 1000 + 6 * 60 * 1000 + 2 * 1000 + 23 * 10; 5 | const CMITimespan = convertMsToCMITimespan(milliseconds); 6 | expect(CMITimespan).toBe("0036:06:02.23"); 7 | }); 8 | 9 | test("convertToTimeInterval", () => { 10 | const milliseconds = 36 * 60 * 60 * 1000 + 6 * 60 * 1000 + 2 * 1000 + 23 * 10; 11 | const timeInterval = convertToTimeInterval(milliseconds); 12 | expect(timeInterval).toBe("P1DT12H6M2S"); 13 | }); 14 | -------------------------------------------------------------------------------- /src/SCORMAdapter.ts: -------------------------------------------------------------------------------- 1 | interface ApiWindow extends Window { 2 | API?: any; 3 | API_1484_11?: any; 4 | } 5 | 6 | export class SCORMAdapter { 7 | private _API: any; 8 | private _isSCORM2004: boolean; 9 | private _errorCallback: Function; 10 | private _lastRequest: { method?: "get" | "set"; key: string } | null; 11 | private _ignorableErrorCodes: { 12 | code: number; 13 | getShouldBeIgnored: () => boolean; 14 | }[] = [ 15 | { code: 0, getShouldBeIgnored: () => true }, 16 | { 17 | code: 403, 18 | getShouldBeIgnored: () => this._isSCORM2004, 19 | }, 20 | { 21 | code: 401, 22 | getShouldBeIgnored: () => 23 | !this._isSCORM2004 && 24 | !!this._lastRequest && 25 | this._lastRequest.method === "get" && 26 | this._lastRequest.key === "cmi.objectives._children", 27 | }, 28 | { 29 | code: 402, 30 | getShouldBeIgnored: () => 31 | this._isSCORM2004 && 32 | !!this._lastRequest && 33 | this._lastRequest.method === "get" && 34 | this._lastRequest.key === "cmi.objectives._children", 35 | }, 36 | { 37 | code: 351, 38 | getShouldBeIgnored: () => 39 | this._lastRequest?.method === "set" && 40 | new RegExp("^cmi.objectives.\\d+.id$").test(this._lastRequest.key) && 41 | !!this.LMSGetValue(this._lastRequest.key), 42 | }, 43 | { 44 | code: 113, 45 | getShouldBeIgnored: () => this._lastRequest?.key === "Terminate", 46 | }, 47 | ]; 48 | 49 | constructor(errorCallback: Function = function () {}) { 50 | this._API = null; 51 | this._isSCORM2004 = false; 52 | this._errorCallback = errorCallback; 53 | this._lastRequest = null; 54 | this._findAndSetAPI(); 55 | } 56 | 57 | get foundAPI() { 58 | return !!this._API; 59 | } 60 | 61 | private _initialize() { 62 | if (this._isSCORM2004) { 63 | this.LMSSetValue("cmi.score.min", 0); 64 | this.LMSSetValue("cmi.score.max", 100); 65 | } else { 66 | this.LMSSetValue("cmi.core.score.min", 0); 67 | this.LMSSetValue("cmi.core.score.max", 100); 68 | } 69 | } 70 | 71 | private _findAndSetAPI() { 72 | if (typeof window === "undefined") { 73 | console.error("Unable to find an API adapter"); 74 | } else { 75 | let theAPI = this._findAPIInWindow(window as unknown as ApiWindow); 76 | if ( 77 | theAPI == null && 78 | window.opener != null && 79 | typeof window.opener != "undefined" 80 | ) { 81 | theAPI = this._findAPIInWindow(window.opener); 82 | } 83 | if (theAPI == null) { 84 | console.error("Unable to find an API adapter"); 85 | } else { 86 | this._API = theAPI["API"]; 87 | this._isSCORM2004 = theAPI["isSCORM2004"]; 88 | } 89 | 90 | if (this._API == null) { 91 | console.error("Couldn't find the API!"); 92 | } 93 | } 94 | } 95 | 96 | private _findAPIInWindow(win: ApiWindow) { 97 | let findAPITries = 0; 98 | while ( 99 | win.API == null && 100 | win.API_1484_11 == null && 101 | win.parent != null && 102 | win.parent != win 103 | ) { 104 | findAPITries++; 105 | if (findAPITries > 7) { 106 | console.error("Error finding API -- too deeply nested."); 107 | return null; 108 | } 109 | win = win.parent as ApiWindow; 110 | } 111 | 112 | if (win.API) { 113 | return { 114 | API: win.API, 115 | isSCORM2004: false, 116 | }; 117 | } else if (win.API_1484_11) { 118 | return { 119 | API: win.API_1484_11, 120 | isSCORM2004: true, 121 | }; 122 | } 123 | return null; 124 | } 125 | 126 | private _callAPIFunction( 127 | fun: string, 128 | args: [(string | number)?, (string | number)?] = [""] 129 | ) { 130 | if (this._API == null) { 131 | this._warnNOAPI(); 132 | return; 133 | } 134 | if (this._isSCORM2004 && fun.indexOf("LMS") == 0) { 135 | fun = fun.substr(3); 136 | } else if (!this._isSCORM2004 && !(fun.indexOf("LMS") == 0)) { 137 | fun = "LMS" + fun; 138 | } 139 | 140 | const result = this._API[fun].apply(this._API, args); 141 | console.info( 142 | `[SCOL-R] ${fun}(${args.join(", ")}) = ${JSON.stringify(result)}` 143 | ); 144 | return result; 145 | } 146 | 147 | private _handleError(functionName: string) { 148 | const lastErrorCode = this.LMSGetLastError(); 149 | const lastErrorString = this.LMSGetErrorString(lastErrorCode); 150 | const lastErrorDiagnostic = this.LMSGetDiagnostic(lastErrorCode); 151 | if ( 152 | !this._ignorableErrorCodes.some( 153 | ({ code, getShouldBeIgnored }) => 154 | code === lastErrorCode && getShouldBeIgnored() 155 | ) 156 | ) { 157 | console.warn( 158 | functionName, 159 | `An error occured on the SCORM API: code ${lastErrorCode}, message: ${lastErrorString}`, 160 | lastErrorDiagnostic 161 | ); 162 | this._errorCallback( 163 | lastErrorString, 164 | lastErrorDiagnostic && lastErrorDiagnostic != lastErrorCode 165 | ? lastErrorDiagnostic 166 | : null 167 | ); 168 | } 169 | } 170 | 171 | private _warnNOAPI() { 172 | console.warn( 173 | "Cannot execute this function because the SCORM API is not available." 174 | ); 175 | this._errorCallback("apiNotFound"); 176 | } 177 | private validateResult(result: unknown) { 178 | if (typeof result === "string") { 179 | return result === "true"; 180 | } else if (typeof result === "boolean") { 181 | return result; 182 | } 183 | return false; 184 | } 185 | 186 | LMSInitialize() { 187 | const functionName = "Initialize"; 188 | const result = this._callAPIFunction(functionName); 189 | const lastErrorCode = this.LMSGetLastError(); 190 | const success = 191 | this.validateResult(result) || 192 | (this._isSCORM2004 193 | ? lastErrorCode === 103 // 103 in 2004.* = already initialized 194 | : lastErrorCode === 101); // 101 in 1.2 = already initialized 195 | 196 | if (success) { 197 | this._initialize(); 198 | } 199 | return success || this._handleError(functionName); 200 | } 201 | 202 | LMSTerminate() { 203 | this._lastRequest = { key: "Terminate" }; 204 | const functionName = this._isSCORM2004 ? "Terminate" : "Finish"; 205 | const result = this._callAPIFunction(functionName); 206 | const success = this.validateResult(result); 207 | return success || this._handleError(functionName); 208 | } 209 | 210 | LMSGetValue(name: string) { 211 | this._lastRequest = { method: "get", key: name }; 212 | const functionName = "GetValue"; 213 | const value = this._callAPIFunction(functionName, [name]); 214 | const success = this.LMSGetLastError() === 0; 215 | return success ? value : this._handleError(`${functionName}: ${name}`); 216 | } 217 | 218 | LMSSetValue(name: string, value: string | number) { 219 | this._lastRequest = { method: "set", key: name }; 220 | const functionName = "SetValue"; 221 | const result = this._callAPIFunction(functionName, [name, value]); 222 | const success = this.validateResult(result); 223 | return success || this._handleError(`${functionName}: {${name}: ${value}}`); 224 | } 225 | 226 | LMSCommit() { 227 | const result = this._callAPIFunction("Commit"); 228 | 229 | if (this.validateResult(result)) { 230 | return true; 231 | } else if ( 232 | typeof result === "object" && 233 | "then" in result && 234 | typeof result.then === "function" 235 | ) { 236 | result.then((success: unknown) => { 237 | if (!this.validateResult(success)) { 238 | this._errorCallback("commitFailed"); 239 | } 240 | }); 241 | return true; 242 | } 243 | this._errorCallback("commitFailed"); 244 | } 245 | 246 | LMSGetLastError() { 247 | return parseInt(this._callAPIFunction("GetLastError")); 248 | } 249 | 250 | LMSGetErrorString(errorCode: number) { 251 | return this._callAPIFunction("GetErrorString", [errorCode]); 252 | } 253 | 254 | LMSGetDiagnostic(errorCode: number) { 255 | return this._callAPIFunction("GetDiagnostic", [errorCode]); 256 | } 257 | 258 | get lastRequest() { 259 | return this._lastRequest; 260 | } 261 | 262 | getDataFromLMS() { 263 | return this.LMSGetValue("cmi.launch_data"); 264 | } 265 | 266 | getLearnerId() { 267 | const CMIVariableName = this._isSCORM2004 268 | ? "cmi.learner_id" 269 | : "cmi.core.student_id"; 270 | return this.LMSGetValue(CMIVariableName); 271 | } 272 | 273 | getLearnerName() { 274 | const CMIVariableName = this._isSCORM2004 275 | ? "cmi.learner_name" 276 | : "cmi.core.student_name"; 277 | return this.LMSGetValue(CMIVariableName); 278 | } 279 | 280 | setStudent(studentId: string, studentName: string) { 281 | if (this._isSCORM2004) { 282 | this.LMSSetValue("cmi.learner_id", studentId); 283 | this.LMSSetValue("cmi.learner_name", studentName); 284 | } else { 285 | this.LMSSetValue("cmi.core.student_id", studentId); 286 | this.LMSSetValue("cmi.core.student_name", studentName); 287 | } 288 | } 289 | 290 | setScore(score: number) { 291 | if (this._isSCORM2004) { 292 | this.LMSSetValue("cmi.score.raw", score); 293 | this.LMSSetValue("cmi.score.scaled", score / 100); 294 | } else { 295 | this.LMSSetValue("cmi.core.score.raw", score); 296 | } 297 | } 298 | 299 | getScore() { 300 | const CMIVariableName = this._isSCORM2004 301 | ? "cmi.score.raw" 302 | : "cmi.core.score.raw"; 303 | let score = this.LMSGetValue(CMIVariableName); 304 | return score; 305 | } 306 | 307 | getLessonStatus() { 308 | const CMIVariableName = this._isSCORM2004 309 | ? "cmi.completion_status" 310 | : "cmi.core.lesson_status"; 311 | return this.LMSGetValue(CMIVariableName); 312 | } 313 | 314 | setLessonStatus(lessonStatus: string) { 315 | if (this._isSCORM2004) { 316 | let successStatus = "unknown"; 317 | if (lessonStatus === "passed" || lessonStatus === "failed") 318 | successStatus = lessonStatus; 319 | this.LMSSetValue("cmi.success_status", successStatus); 320 | let completionStatus = "unknown"; 321 | if (lessonStatus === "passed" || lessonStatus === "completed") { 322 | completionStatus = "completed"; 323 | } else if (lessonStatus === "incomplete") { 324 | completionStatus = "incomplete"; 325 | } else if ( 326 | lessonStatus === "not attempted" || 327 | lessonStatus === "browsed" 328 | ) { 329 | completionStatus = "not attempted"; 330 | } 331 | this.LMSSetValue("cmi.completion_status", completionStatus); 332 | } else { 333 | this.LMSSetValue("cmi.core.lesson_status", lessonStatus); 334 | } 335 | } 336 | 337 | setSessionTime(msSessionTime: number) { 338 | if (this._isSCORM2004) { 339 | const duration = convertToTimeInterval(msSessionTime); 340 | this.LMSSetValue("cmi.session_time", duration); 341 | } else { 342 | const duration = convertMsToCMITimespan(msSessionTime); 343 | this.LMSSetValue("cmi.core.session_time", duration); 344 | } 345 | } 346 | 347 | get objectivesAreAvailable() { 348 | const objectivesFields = !!this.LMSGetValue("cmi.objectives._children"); 349 | return objectivesFields && this.LMSGetLastError() === 0; 350 | } 351 | 352 | setObjectives(objectivesIds: string[]) { 353 | objectivesIds.forEach((objectiveId, index) => { 354 | this.LMSSetValue(`cmi.objectives.${index}.id`, objectiveId); 355 | }); 356 | } 357 | 358 | get objectives() { 359 | const objectives = []; 360 | const objectivesNbr = this.LMSGetValue("cmi.objectives._count"); 361 | for (let index = 0; index < objectivesNbr; index++) { 362 | objectives.push(this.LMSGetValue(`cmi.objectives.${index}.id`)); 363 | } 364 | return objectives; 365 | } 366 | 367 | setObjectiveScore(objectiveId: string, score: number) { 368 | const objectivesNbr = this.LMSGetValue("cmi.objectives._count"); 369 | for (let index = 0; index < objectivesNbr; index++) { 370 | const storedObjectiveId = this.LMSGetValue(`cmi.objectives.${index}.id`); 371 | if (objectiveId === storedObjectiveId) { 372 | this.LMSSetValue(`cmi.objectives.${index}.score.raw`, score); 373 | return; 374 | } 375 | } 376 | } 377 | 378 | setObjectiveStatus(objectiveId: string, status: "completed" | "incomplete") { 379 | const objectivesNbr = this.LMSGetValue("cmi.objectives._count"); 380 | for (let index = 0; index < objectivesNbr; index++) { 381 | const storedObjectiveId = this.LMSGetValue(`cmi.objectives.${index}.id`); 382 | if (objectiveId === storedObjectiveId) { 383 | if (this._isSCORM2004) { 384 | this.LMSSetValue( 385 | `cmi.objectives.${index}.success_status`, 386 | status === "completed" ? "passed" : "unknown" 387 | ); 388 | this.LMSSetValue( 389 | `cmi.objectives.${index}.completion_status`, 390 | status === "completed" ? "completed" : "incomplete" 391 | ); 392 | } else { 393 | this.LMSSetValue( 394 | `cmi.objectives.${index}.status`, 395 | status === "completed" ? "passed" : "incomplete" 396 | ); 397 | } 398 | return; 399 | } 400 | } 401 | } 402 | 403 | getObjectiveScore(objectiveId: string) { 404 | const objectivesNbr = this.LMSGetValue("cmi.objectives._count"); 405 | for (let index = 0; index < objectivesNbr; index++) { 406 | const storedObjectiveId = this.LMSGetValue(`cmi.objectives.${index}.id`); 407 | if (objectiveId === storedObjectiveId) { 408 | return this.LMSGetValue(`cmi.objectives.${index}.score.raw`); 409 | } 410 | } 411 | } 412 | 413 | setSuspendData(data: string) { 414 | this.LMSSetValue("cmi.suspend_data", data); 415 | } 416 | get suspendData() { 417 | return this.LMSGetValue("cmi.suspend_data"); 418 | } 419 | } 420 | 421 | export const convertToTimeInterval = (milliseconds: number) => { 422 | // timeinterval (second,10,2), 423 | const data = getDurationData(milliseconds); 424 | const days = data.days; 425 | const hours = data.hours % 24; 426 | const minutes = data.minutes % 60; 427 | const seconds = data.seconds % 60; 428 | const cents = data.cents % 100; 429 | 430 | const daysString = days ? days + "D" : ""; 431 | const hoursString = hours ? hours + "H" : ""; 432 | const minutesString = minutes ? minutes + "M" : ""; 433 | const secondsString = (seconds || "0" + (cents ? "." + cents : "")) + "S"; 434 | 435 | const hms = [hoursString, minutesString, secondsString].join(""); 436 | return "P" + daysString + "T" + hms; 437 | }; 438 | 439 | export const convertMsToCMITimespan = (milliseconds: number) => { 440 | // CMITimespan "0000:00:00.00" 441 | const { seconds, minutes, hours, cents } = getDurationData(milliseconds); 442 | const h = pad(hours, 4); 443 | const m = pad(minutes % 60, 2); 444 | const s = pad(seconds % 60, 2); 445 | const c = pad(cents % 100, 2); 446 | return `${h}:${m}:${s}.${c}`; 447 | }; 448 | 449 | const getDurationData = ( 450 | milliseconds: number 451 | ): { 452 | days: number; 453 | hours: number; 454 | minutes: number; 455 | seconds: number; 456 | cents: number; 457 | } => { 458 | const cents = Math.floor(milliseconds / 10); 459 | const seconds = Math.floor(milliseconds / 1000); 460 | const minutes = Math.floor(seconds / 60); 461 | const hours = Math.floor(minutes / 60); 462 | const days = Math.floor(hours / 24); 463 | return { days, hours, minutes, seconds, cents }; 464 | }; 465 | 466 | const pad = (value: number, targetLength: number): string => { 467 | const text = value.toString(); 468 | const padLength = targetLength - text.length; 469 | if (padLength <= 0) return text; 470 | 471 | return "0".repeat(padLength) + text; 472 | }; 473 | -------------------------------------------------------------------------------- /src/hashString.test.ts: -------------------------------------------------------------------------------- 1 | import { hashString } from "./hashString"; 2 | 3 | test("hashString", async () => { 4 | const hashedFoo = await hashString("foo"); 5 | const hashedBar = await hashString("bar"); 6 | const hashedFooAgain = await hashString("foo"); 7 | 8 | // Hashed values should be different than the original strings 9 | expect(hashedFoo).not.toBe("foo"); 10 | expect(hashedBar).not.toBe("bar"); 11 | 12 | // Hashed values should be different for different strings 13 | expect(hashedFoo).not.toBe(hashedBar); 14 | 15 | // Hashed values multiple times should be the same 16 | expect(hashedFoo).toBe(hashedFooAgain); 17 | }); 18 | -------------------------------------------------------------------------------- /src/hashString.ts: -------------------------------------------------------------------------------- 1 | export async function hashString(string: string) { 2 | const utf8 = new TextEncoder().encode(string); 3 | const hashBuffer = await crypto.subtle.digest("SHA-256", utf8); 4 | return Array.from(new Uint8Array(hashBuffer)) 5 | .map((bytes) => bytes.toString(16).padStart(2, "0")) 6 | .join(""); 7 | } 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ManifestGenerator"; 2 | export * from "./SCORMAdapter"; 3 | export * from "./HTMLGenerator"; 4 | export { MessageEmitter } from "./MessageHandler"; 5 | 6 | export const libFiles = [ 7 | "loadContent.js", 8 | "MessageHandler.js", 9 | "SCORMAdapter.js", 10 | "hashString.js", 11 | ] as const; 12 | 13 | export const scormVersions = [ 14 | "1.2", 15 | "2004 3rd Edition", 16 | "2004 4th Edition", 17 | ] as const; 18 | 19 | export enum LessonStatus { 20 | Passed = "passed", 21 | Failed = "failed", 22 | Completed = "completed", 23 | Incomplete = "incomplete", 24 | NotAttempted = "not attempted", 25 | Browsed = "browsed", 26 | } 27 | -------------------------------------------------------------------------------- /src/loadContent.js: -------------------------------------------------------------------------------- 1 | export async function loadContent({ hashIdentifiers = false } = {}) { 2 | var messages = { 3 | en: { 4 | pageTitle: "Your content is loading...", 5 | pageSubtitle: 6 | "Please wait, or if your content doesn't appear, try closing and opening this window again.", 7 | pageErrorMessagesTitle: 8 | "If the initialization fails, error messages will appear below:", 9 | pageFooter: 10 | 'This content is loaded via SCOL-R, a cross-domain SCORM connector created by Didask.', 11 | apiNotFound: 12 | "

We were not able to contact your LMS: please close this window and try again later.

", 13 | couldNotInitialize: 14 | "

We were not able to initialize the connection with your LMS: please close this window and try again later.

", 15 | learnerIdMissing: 16 | "

We could not get your learner ID from the LMS: please close this window and try again later.

", 17 | sourceUrlMissing: 18 | "

We could find the address of the remote resource: it looks like this module is invalid, please contact your LMS administrator.

", 19 | runtimeErrorTitle: "An error occurred:", 20 | commitFailed: 21 | "The intermediate recording could not succeed. Please close the window and try again later, or contact your administrator if the problem keeps occurring.", 22 | }, 23 | fr: { 24 | pageTitle: "Votre contenu est en cours de chargement...", 25 | pageSubtitle: 26 | "Merci de patienter ; si votre contenu ne se charge pas, veuillez essayer de fermer et d'ouvrir cette fenêtre à nouveau.", 27 | pageErrorMessagesTitle: 28 | "Si l'initialisation échoue, les messages d'erreur apparaîtront ci-dessous :", 29 | pageFooter: 30 | 'Ce contenu est chargé via SCOL-R, un connecteur SCORM cross-domaine créé par Didask.', 31 | apiNotFound: 32 | "

Nous n'avons pas pu contacter votre LMS : veuillez fermer cette fenêtre et réessayer plus tard.

", 33 | couldNotInitialize: 34 | "

Nous n'avons pas pu initialiser la connection avec votre LMS : veuillez fermer cette fenêtre et réessayer plus tard.

", 35 | learnerIdMissing: 36 | "

Nous n'avons pas pu obtenir votre identifiant depuis le LMS : veuillez fermer cette fenêtre et réessayer plus tard.

", 37 | sourceUrlMissing: 38 | "

Nous n'avons pas pu trouver l'adresse de la ressource distante : il semble que ce module est invalide, veuillez contacter l'administrateur de votre LMS.

", 39 | runtimeErrorTitle: "Une erreur s'est produite :", 40 | commitFailed: 41 | "La sauvegarde automatique intermédiaire n’a pu aboutir. Si cette erreur se produit fréquemment veuillez fermer votre contenu et retenter plus tard ou contacter votre administrateur.", 42 | }, 43 | }; 44 | 45 | var localizeMessage = function (message) { 46 | var locale = navigator.language || navigator.userLanguage; 47 | if (locale) locale = locale.split(/[_-]/)[0]; 48 | if (!messages.hasOwnProperty(locale)) locale = "en"; 49 | var localizedMessages = messages[locale]; 50 | return localizedMessages.hasOwnProperty(message) 51 | ? localizedMessages[message] 52 | : message; 53 | }; 54 | 55 | document.getElementById("title").innerHTML = localizeMessage("pageTitle"); 56 | document.getElementById("subtitle").innerHTML = 57 | localizeMessage("pageSubtitle"); 58 | document.getElementById("footer-content").innerHTML = 59 | localizeMessage("pageFooter"); 60 | document.getElementById("title-error-messages").innerHTML = localizeMessage( 61 | "pageErrorMessagesTitle" 62 | ); 63 | 64 | var displayInitError = function (message) { 65 | var messagesContainer = document.getElementsByClassName("messages"); 66 | var newMessage = document.createElement("p"); 67 | var localizedMessage = localizeMessage(message); 68 | newMessage.innerHTML = localizedMessage; 69 | messagesContainer.length && messagesContainer[0].appendChild(newMessage); 70 | console.error(localizedMessage); 71 | }; 72 | 73 | var displayRuntimeError = function () { 74 | var errorContainer = document.getElementById("runtime-error"); 75 | if (!(arguments && arguments.length)) { 76 | errorContainer.innerHTML = ""; 77 | return; 78 | } 79 | errorContainer.innerHTML = 80 | "
" + localizeMessage("runtimeErrorTitle") + "
"; 81 | for (var i = 0; i < arguments.length; i++) { 82 | if (!arguments[i]) continue; 83 | var thisError = document.createElement("p"); 84 | thisError.innerHTML = localizeMessage(arguments[i]); 85 | errorContainer.appendChild(thisError); 86 | } 87 | // Remove the messages after 6 seconds 88 | setTimeout(function () { 89 | errorContainer.innerHTML = ""; 90 | }, 6000); 91 | }; 92 | 93 | var ADAPTER = new SCORMAdapter(displayRuntimeError); 94 | if (!ADAPTER.foundAPI) { 95 | displayInitError("apiNotFound"); 96 | return; 97 | } 98 | if (!ADAPTER.LMSInitialize()) { 99 | displayInitError("couldNotInitialize"); 100 | return; 101 | } 102 | var lessonStatus = ADAPTER.getLessonStatus(); 103 | if (lessonStatus === "not attempted") { 104 | ADAPTER.setLessonStatus("incomplete"); 105 | } 106 | 107 | var sourceUrl = document.body.getAttribute("data-source"); 108 | if (!sourceUrl) { 109 | displayInitError("sourceUrlMissing"); 110 | return; 111 | } 112 | 113 | var sourceUrlParser = document.createElement("a"); 114 | sourceUrlParser.href = sourceUrl; 115 | 116 | var learnerId = ADAPTER.getLearnerId(); 117 | var learnerName = ADAPTER.getLearnerName(); 118 | if (learnerId == null) { 119 | displayInitError("learnerIdMissing"); 120 | return; 121 | } 122 | 123 | if (hashIdentifiers) { 124 | learnerId = await hashString(learnerId); 125 | learnerName = await hashString(learnerName); 126 | } 127 | 128 | sourceUrlParser.search += 129 | (sourceUrlParser.search.startsWith("?") ? "&" : "?") + 130 | "scorm" + 131 | `&learner_id=${learnerId}` + 132 | `&learner_name=${learnerName}` + 133 | `&lms_origin=${encodeURIComponent(location.origin)}` + 134 | `&are_identifiers_hashed=${hashIdentifiers}`; 135 | 136 | var iframe = document.createElement("iframe"); 137 | iframe.setAttribute("src", sourceUrlParser.href); 138 | iframe.setAttribute("frameborder", "0"); 139 | iframe.setAttribute("height", "100%"); 140 | iframe.setAttribute("width", "100%"); 141 | iframe.setAttribute("allow", "microphone"); 142 | document.body.insertBefore(iframe, document.getElementById("wrapper")); 143 | 144 | var sessionStart = new Date(); 145 | 146 | var host = sourceUrlParser.host; 147 | // The `host` variable may or may not contain the port number depending on the browser. 148 | // We remove it if it wasnt' explicitly set. 149 | if ( 150 | host.indexOf(":") > -1 && 151 | sourceUrl.indexOf(host) !== sourceUrlParser.protocol.length + 2 152 | ) { 153 | host = host.slice(0, host.indexOf(":")); 154 | } 155 | var sourceOrigin = sourceUrlParser.protocol + "//" + host; 156 | new MessageReceiver(window, sourceOrigin, ADAPTER); 157 | 158 | var sessionStart = new Date().getTime(); 159 | 160 | /* 161 | * In case the beforeunload event is not triggered, we still want to send the session time to the LMS. 162 | * This is why we send the session time every 10 seconds. 163 | */ 164 | setInterval(() => { 165 | var now = new Date().getTime(); 166 | ADAPTER.setSessionTime(now - sessionStart); 167 | }, 10_000); 168 | 169 | window.addEventListener("beforeunload", function (e) { 170 | var sessionEnd = new Date().getTime(); 171 | ADAPTER.setSessionTime(sessionEnd - sessionStart); 172 | 173 | ADAPTER.LMSTerminate(); 174 | }); 175 | } 176 | -------------------------------------------------------------------------------- /src/loadContent.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { loadContent } from "./loadContent"; 6 | import { HTMLGenerator } from "./HTMLGenerator"; 7 | import { SCORMAdapter } from "./SCORMAdapter"; 8 | import { MessageReceiver } from "./MessageHandler"; 9 | import { createHash } from "crypto"; 10 | 11 | const fakeAPI12Values = { 12 | "cmi.core.student_id": "JohnDoeID", 13 | "cmi.core.student_name": "John Doe", 14 | "cmi.launch_data": '{"lms_origin":"http://localhost"}', 15 | }; 16 | 17 | const fakeAPI2004Values = { 18 | "cmi.learner_id": "JohnDoeID", 19 | "cmi.learner_name": "John Doe", 20 | "cmi.launch_data": '{"lms_origin":"http://localhost"}', 21 | }; 22 | 23 | declare global { 24 | interface Window { 25 | SCORMAdapter: typeof SCORMAdapter; 26 | MessageReceiver: typeof MessageReceiver; 27 | hashString: (str: string) => Promise; 28 | 29 | // 1.2 API 30 | API?: { 31 | LMSInitialize: () => boolean; 32 | LMSFinish: VoidFunction; 33 | LMSGetValue: (key: string) => string; 34 | LMSSetValue: () => boolean; 35 | LMSCommit: VoidFunction; 36 | LMSGetLastError: () => number; 37 | LMSGetErrorString: VoidFunction; 38 | LMSGetDiagnostic: VoidFunction; 39 | }; 40 | 41 | // 2004 API 42 | API_1484_11?: { 43 | Initialize: () => boolean; 44 | Terminate: VoidFunction; 45 | GetValue: (key: string) => string; 46 | SetValue: () => boolean; 47 | Commit: VoidFunction; 48 | GetLastError: () => number; 49 | GetErrorString: VoidFunction; 50 | GetDiagnostic: VoidFunction; 51 | }; 52 | } 53 | } 54 | 55 | function initializeAPI12() { 56 | window.API = { 57 | LMSInitialize: jest.fn(() => true), 58 | LMSFinish: jest.fn(), 59 | LMSGetValue: jest.fn((key) => fakeAPI12Values[key] || ""), 60 | LMSSetValue: jest.fn(() => true), 61 | LMSCommit: jest.fn(), 62 | LMSGetLastError: jest.fn(() => 0), 63 | LMSGetErrorString: jest.fn(), 64 | LMSGetDiagnostic: jest.fn(), 65 | }; 66 | } 67 | 68 | function initializeAPI2004() { 69 | window.API_1484_11 = { 70 | Initialize: jest.fn(() => true), 71 | Terminate: jest.fn(), 72 | GetValue: jest.fn((key) => fakeAPI2004Values[key] || ""), 73 | SetValue: jest.fn(() => true), 74 | Commit: jest.fn(), 75 | GetLastError: jest.fn(() => 0), 76 | GetErrorString: jest.fn(), 77 | GetDiagnostic: jest.fn(), 78 | }; 79 | } 80 | 81 | // This method is mocked because some methods are only available in the browser 82 | const mockHashString = jest.fn((str: string) => { 83 | return Promise.resolve(createHash("SHA-256").update(str).digest("hex")); 84 | }); 85 | 86 | window.SCORMAdapter = SCORMAdapter; 87 | window.MessageReceiver = MessageReceiver; 88 | window.hashString = mockHashString; 89 | 90 | const error = window.console.error; 91 | 92 | describe("loadContent", () => { 93 | beforeEach(() => { 94 | document.documentElement.innerHTML = HTMLGenerator({ 95 | dataSource: "https://www.example.com", 96 | }); 97 | window.console.error = error; 98 | delete window.API; 99 | delete window.API_1484_11; 100 | }); 101 | 102 | test("should call loadContent successfully without an API", () => { 103 | const mockError = jest.fn(); 104 | window.console.error = mockError; 105 | 106 | loadContent(); 107 | expect(mockError).toHaveBeenCalledTimes(3); 108 | 109 | // Errors are expected because we are not in an lms and the API is not mocked 110 | expect(mockError.mock.calls).toEqual([ 111 | ["Unable to find an API adapter"], 112 | ["Couldn't find the API!"], 113 | [ 114 | "

We were not able to contact your LMS: please close this window and try again later.

", 115 | ], 116 | ]); 117 | }); 118 | 119 | test("should call loadContent successfully with the 1.2 API", async () => { 120 | const mockError = jest.fn(); 121 | window.console.error = mockError; 122 | 123 | initializeAPI12(); 124 | 125 | await loadContent(); 126 | expect(mockError).not.toHaveBeenCalled(); 127 | 128 | const iframe = document.querySelector("iframe"); 129 | expect(iframe).not.toBeNull(); 130 | 131 | const iframeSrc = iframe?.getAttribute("src"); 132 | expect(iframeSrc).toBe( 133 | "https://www.example.com/?scorm&learner_id=JohnDoeID&learner_name=John%20Doe&lms_origin=http%3A%2F%2Flocalhost&are_identifiers_hashed=false" 134 | ); 135 | }); 136 | 137 | test("should call loadContent successfully with the 1.2 API and hashIdentifiers set to true", async () => { 138 | initializeAPI12(); 139 | await loadContent({ hashIdentifiers: true }); 140 | 141 | const iframeSrc = document.querySelector("iframe")?.getAttribute("src"); 142 | expect(iframeSrc).toBe( 143 | "https://www.example.com/?scorm&learner_id=f10110c925871dededae1bd23e33d012bfeba9c8bcbe08762628e8f94dbc5636&learner_name=6cea57c2fb6cbc2a40411135005760f241fffc3e5e67ab99882726431037f908&lms_origin=http%3A%2F%2Flocalhost&are_identifiers_hashed=true" 144 | ); 145 | }); 146 | 147 | test("should call loadContent successfully with the 2004 API", async () => { 148 | const mockError = jest.fn(); 149 | window.console.error = mockError; 150 | 151 | initializeAPI2004(); 152 | 153 | await loadContent({ hashIdentifiers: false }); 154 | expect(mockError).not.toHaveBeenCalled(); 155 | 156 | const iframe = document.querySelector("iframe"); 157 | expect(iframe).not.toBeNull(); 158 | 159 | const iframeSrc = iframe?.getAttribute("src"); 160 | expect(iframeSrc).toBe( 161 | "https://www.example.com/?scorm&learner_id=JohnDoeID&learner_name=John%20Doe&lms_origin=http%3A%2F%2Flocalhost&are_identifiers_hashed=false" 162 | ); 163 | }); 164 | 165 | test("should call loadContent successfully with the 2004 API and hashIdentifiers set to true", async () => { 166 | initializeAPI2004(); 167 | await loadContent({ hashIdentifiers: true }); 168 | 169 | const iframeSrc = document.querySelector("iframe")?.getAttribute("src"); 170 | expect(iframeSrc).toBe( 171 | "https://www.example.com/?scorm&learner_id=f10110c925871dededae1bd23e33d012bfeba9c8bcbe08762628e8f94dbc5636&learner_name=6cea57c2fb6cbc2a40411135005760f241fffc3e5e67ab99882726431037f908&lms_origin=http%3A%2F%2Flocalhost&are_identifiers_hashed=true" 172 | ); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./lib", 4 | "noImplicitAny": true, 5 | "declaration": true, 6 | "module": "commonjs", 7 | "target": "es5", 8 | "lib": ["es2017", "dom"], 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "resolveJsonModule": true, 12 | "esModuleInterop": true, 13 | "isolatedModules": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "allowJs": true, 17 | "skipLibCheck": true, 18 | "strict": true 19 | }, 20 | "include": ["./src/*.ts", "./src/*.js"], 21 | "exclude": ["node_modules", "**/*.test.ts"] 22 | } 23 | --------------------------------------------------------------------------------