├── .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 |
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 |
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 |
--------------------------------------------------------------------------------