diff --git a/task-launcher/cypress.config.js b/task-launcher/cypress.config.js index 589b8c18..0c20b995 100644 --- a/task-launcher/cypress.config.js +++ b/task-launcher/cypress.config.js @@ -1,8 +1,47 @@ import { defineConfig } from 'cypress'; +const LANGUAGE_OPTIONS_URL = + 'https://storage.googleapis.com/levante-assets-dev/translations/dashboard-consolidated-flat/languageoptions.json'; + +async function buildLanguageLocaleTaskMatrix() { + const res = await fetch(LANGUAGE_OPTIONS_URL); + if (!res.ok) { + throw new Error(`Failed to fetch language options: ${res.status} ${res.statusText}`); + } + const languageOptions = await res.json(); + const seen = new Set(); + + const matrix = Object.entries(languageOptions).flatMap(([locale, cfg]) => { + if (locale === 'en-US') { + return []; + } + if (!cfg || !Array.isArray(cfg.taskOptions)) { + return []; + } + return cfg.taskOptions + .filter((task) => { + const key = `${locale}\0${task}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }) + .map((task) => ({ locale, task })); + }); + + if (matrix.length === 0) { + throw new Error( + 'languageoptions.json produced an empty test matrix (no locales with taskOptions).', + ); + } + + return matrix; +} + export default defineConfig({ e2e: { - setupNodeEvents(on, config) { + async setupNodeEvents(on, config) { // implement node event listeners here on('task', { progress(message) { @@ -12,6 +51,16 @@ export default defineConfig({ return null; }, }); + + const matrix = await buildLanguageLocaleTaskMatrix(); + + return { + ...config, + env: { + ...config.env, + languageLocaleTaskMatrix: matrix, + }, + }; }, // Video recording settings video: true, diff --git a/task-launcher/cypress/e2e/task_locales.cy.js b/task-launcher/cypress/e2e/task_locales.cy.js index 2225062b..b8b302f7 100644 --- a/task-launcher/cypress/e2e/task_locales.cy.js +++ b/task-launcher/cypress/e2e/task_locales.cy.js @@ -1,29 +1,37 @@ -const LOCALES = ['de-DE', 'es-CO', 'es-AR']; - -const TASKS = [ - 'intro', - 'egma-math', - 'matrix-reasoning', - 'mental-rotation', - 'hearts-and-flowers', - 'memory-game', - 'same-different-selection', - 'trog', - 'vocab', - 'theory-of-mind', - 'hostile-attribution', - 'child-survey', -]; +/* global cy, describe, expect, it, Cypress */ function visitTaskWithLocaleAndEnterFullscreen(task, lng) { cy.visit(`http://localhost:8080/?task=${task}&lng=${lng}`); cy.get('button.primary').should('be.visible').first().realClick(); } -describe('tasks load in non-English locales (fullscreen only)', () => { - TASKS.forEach((task) => { +function groupLocalesByTask(matrix) { + const byTask = {}; + matrix.forEach(({ locale, task }) => { + if (!byTask[task]) { + byTask[task] = []; + } + byTask[task].push(locale); + }); + return byTask; +} + +describe('tasks load per languageoptions.json (fullscreen only)', () => { + const matrix = Cypress.env('languageLocaleTaskMatrix'); + + if (!Array.isArray(matrix) || matrix.length === 0) { + it('fails when languageLocaleTaskMatrix is not preloaded (see cypress.config.js)', () => { + expect(matrix).to.be.an('array'); + expect(matrix).to.have.length.greaterThan(0); + }); + return; + } + + const byTask = groupLocalesByTask(matrix); + + Object.entries(byTask).forEach(([task, locales]) => { describe(task, () => { - LOCALES.forEach((lng) => { + locales.forEach((lng) => { it(`lng=${lng}`, () => { visitTaskWithLocaleAndEnterFullscreen(task, lng); }); diff --git a/task-launcher/package-lock.json b/task-launcher/package-lock.json index a41fcbbf..c52c37c6 100644 --- a/task-launcher/package-lock.json +++ b/task-launcher/package-lock.json @@ -23043,24 +23043,6 @@ } } }, - "node_modules/vitest/node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", diff --git a/task-launcher/serve/firebaseConfig.js b/task-launcher/serve/firebaseConfig.js deleted file mode 100644 index e28adf41..00000000 --- a/task-launcher/serve/firebaseConfig.js +++ /dev/null @@ -1,22 +0,0 @@ -// ONLY FOR STANDALONE (WEB APP) - -// Change this to use your projects database API keys -const devFirebaseConfig = { - apiKey: 'AIzaSyCOzRA9a2sDHtVlX7qnszxrgsRCBLyf5p0', - authDomain: 'hs-levante-admin-dev.firebaseapp.com', - projectId: 'hs-levante-admin-dev', - storageBucket: 'hs-levante-admin-dev.firebasestorage.app', - messagingSenderId: '41590333418', - appId: '1:41590333418:web:3468a7caadab802d6e5c93', -}; - -const productionFirebaseConfig = { - apiKey: 'AIzaSyCcnmBCojjK0_Ia87f0SqclSOihhKVD3f8', - authDomain: 'hs-levante-admin-prod.firebaseapp.com', - projectId: 'hs-levante-admin-prod', - storageBucket: 'hs-levante-admin-prod.firebasestorage.app', - messagingSenderId: '348449903279', - appId: '1:348449903279:web:a1b9dad734e2237c7ffa5a', -}; - -export const firebaseConfig = ENV === 'production' ? productionFirebaseConfig : devFirebaseConfig; diff --git a/task-launcher/serve/serve.js b/task-launcher/serve/serve.js index 87bb9e11..3b9de8b7 100644 --- a/task-launcher/serve/serve.js +++ b/task-launcher/serve/serve.js @@ -1,9 +1,6 @@ -import { RoarAppkit, initializeFirebaseProject } from '@levante-framework/firekit'; -import { onAuthStateChanged, signInAnonymously } from 'firebase/auth'; import * as Sentry from '@sentry/browser'; import i18next from 'i18next'; import { TaskLauncher } from '../src'; -import { firebaseConfig } from './firebaseConfig'; import { stringToBoolean } from '../src/tasks/shared/helpers/stringToBoolean'; import firebaseJSON from '../firebase.json'; @@ -68,63 +65,40 @@ const emulatorConfig = EMULATORS ? firebaseJSON.emulators : undefined; const demoMode = DEMO; async function startWebApp() { - const appKit = await initializeFirebaseProject(firebaseConfig, 'admin', emulatorConfig, 'none'); - onAuthStateChanged(appKit.auth, (user) => { - if (user) { - const userInfo = { - assessmentUid: user.uid, - userMetadata: {}, - }; + const firekit = null; + const gameParams = { + taskName, + skipInstructions, + sequentialPractice, + sequentialStimulus, + corpus, + buttonLayout, + numOfPracticeTrials, + numberOfTrials, + maxIncorrect, + stimulusBlocks, + keyHelpers, + language: language ?? i18next.language, + age, + maxTime, + storeItemId, + cat, + inferenceNumStories, + numberOfStories, + semThreshold, + startingTheta, + heavyInstructions, + demoMode, + version, + debug, + }; + const userParams = { + pid, + }; + const task = new TaskLauncher(firekit, gameParams, userParams); + task.run(); - const userParams = { - pid, - }; - - const gameParams = { - taskName, - skipInstructions, - sequentialPractice, - sequentialStimulus, - corpus, - buttonLayout, - numOfPracticeTrials, - numberOfTrials, - maxIncorrect, - stimulusBlocks, - keyHelpers, - language: language ?? i18next.language, - age, - maxTime, - storeItemId, - cat, - inferenceNumStories, - numberOfStories, - semThreshold, - startingTheta, - heavyInstructions, - demoMode, - version, - debug, - }; - - const taskInfo = { - taskId: taskName, - variantParams: gameParams, - }; - - const firekit = new RoarAppkit({ - firebaseProject: appKit, - taskInfo, - userInfo, - }); - - const task = new TaskLauncher(firekit, gameParams, userParams); - task.run(); - } - }); - - await signInAnonymously(appKit.auth); } await startWebApp(); diff --git a/task-launcher/src/index.ts b/task-launcher/src/index.ts index 3ac2fe9e..a7ecd1d3 100644 --- a/task-launcher/src/index.ts +++ b/task-launcher/src/index.ts @@ -25,9 +25,9 @@ let sharedVisualAssets: MediaAssetsType; export class TaskLauncher { gameParams: GameParamsType; userParams: UserParamsType; - firekit: RoarAppkit; + firekit: RoarAppkit | null; logger?: LevanteLogger; - constructor(firekit: RoarAppkit, gameParams: GameParamsType, userParams: UserParamsType, logger?: LevanteLogger) { + constructor(firekit: RoarAppkit | null, gameParams: GameParamsType, userParams: UserParamsType, logger?: LevanteLogger) { this.gameParams = gameParams; this.userParams = userParams; this.firekit = firekit; @@ -35,7 +35,7 @@ export class TaskLauncher { } async init() { - if (!this.gameParams.demoMode) { + if (!this.gameParams.demoMode && this.firekit) { await this.firekit.startRun(); } @@ -55,7 +55,7 @@ export class TaskLauncher { const { setConfig, getCorpus, buildTaskTimeline, getTranslations } = taskConfig[dashToCamelCase(taskName) as keyof typeof taskConfig]; - const isDev = this.firekit.firebaseProject?.firebaseApp?.options?.projectId === 'hs-levante-admin-dev'; + const isDev = this.firekit ? this.firekit.firebaseProject?.firebaseApp?.options?.projectId === 'hs-levante-admin-dev' : !!this.gameParams.demoMode; const taskVisualBucket = getBucketName(taskName, isDev, 'visual', language); const sharedVisualBucket = getBucketName('shared', isDev, 'visual', language); const languageAudioBucket = getBucketName('shared', isDev, 'audio', language); @@ -117,7 +117,7 @@ export class TaskLauncher { const translations = taskStore().translations; const pageSetup = new InitPageSetup(4000, translations); pageSetup.init(); - const checkTaskFinished = this.gameParams.demoMode + const checkTaskFinished = (this.gameParams.demoMode || this.firekit === null) ? () => taskStore().taskComplete : () => this.firekit?.run?.completed === true && taskStore().taskComplete; diff --git a/task-launcher/src/tasks/shared/helpers/baseTimeline.ts b/task-launcher/src/tasks/shared/helpers/baseTimeline.ts index 5acff508..66041fd5 100644 --- a/task-launcher/src/tasks/shared/helpers/baseTimeline.ts +++ b/task-launcher/src/tasks/shared/helpers/baseTimeline.ts @@ -11,10 +11,12 @@ export const initTimeline = ( const beginningTimeline = { timeline: initialTimeline, on_timeline_finish: async () => { - await config.firekit.updateUser({ - assessmentPid: config.pid || makePid(), - ...config.userMetadata, - }); + if (config.firekit) { + await config.firekit.updateUser({ + assessmentPid: config.pid || makePid(), + ...config.userMetadata, + }); + } startAppTimer(config.maxTime, finishExperiment); }, diff --git a/task-launcher/src/tasks/shared/helpers/config.ts b/task-launcher/src/tasks/shared/helpers/config.ts index 6d9d5b96..35ae7018 100644 --- a/task-launcher/src/tasks/shared/helpers/config.ts +++ b/task-launcher/src/tasks/shared/helpers/config.ts @@ -61,7 +61,7 @@ const defaultCorpus: Record = { }; export const setSharedConfig = async ( - firekit: RoarAppkit, + firekit: RoarAppkit | null, gameParams: GameParamsType, userParams: UserParamsType, ): Promise => { @@ -138,9 +138,5 @@ export const setSharedConfig = async ( config.corpus = defaultCorpus[camelize(taskName)]; } - const updatedGameParams = Object.fromEntries( - Object.entries(gameParams).map(([key, value]) => [key, config[key as keyof typeof config] ?? value]), - ); - return config; }; diff --git a/task-launcher/src/tasks/shared/helpers/isRoarApp.ts b/task-launcher/src/tasks/shared/helpers/isRoarApp.ts index 13d0237e..e6835dcf 100644 --- a/task-launcher/src/tasks/shared/helpers/isRoarApp.ts +++ b/task-launcher/src/tasks/shared/helpers/isRoarApp.ts @@ -8,7 +8,10 @@ const roarFirebaseProjects = [ 'gse-roar-admin-dev', ]; -export function isRoarApp(_firekit: RoarAppkit) { +export function isRoarApp(_firekit: RoarAppkit | null) { + if (!_firekit) { + return false; + } const projectId = _firekit?.firebaseProject?.firebaseApp?.options?.projectId ?? ''; return roarFirebaseProjects.includes(projectId); } diff --git a/task-launcher/src/tasks/shared/helpers/recordCompletion.ts b/task-launcher/src/tasks/shared/helpers/recordCompletion.ts index b51b97cf..47e76716 100644 --- a/task-launcher/src/tasks/shared/helpers/recordCompletion.ts +++ b/task-launcher/src/tasks/shared/helpers/recordCompletion.ts @@ -1,7 +1,7 @@ import { taskStore } from '../../../taskStore'; export function recordCompletion(config: Record) { - if (!config?.firekit?.run?.completed && !taskStore().demoMode) { + if (!taskStore().demoMode && config.firekit && !config.firekit?.run?.completed) { config.firekit.finishRun(); } } diff --git a/task-launcher/src/tasks/shared/helpers/trialSaving.ts b/task-launcher/src/tasks/shared/helpers/trialSaving.ts index b74b4c9a..e4eb9f3b 100644 --- a/task-launcher/src/tasks/shared/helpers/trialSaving.ts +++ b/task-launcher/src/tasks/shared/helpers/trialSaving.ts @@ -119,21 +119,8 @@ export const initTrialSaving = (config: Record) => { // @ts-ignore jsPsych.opts.on_finish = extend(jsPsych.opts.on_finish, () => { - // Add finishing metadata to run doc - // const finishingMetadata = {} - // const { maxTimeReached, numIncorrect, maxIncorrect } = taskStore(); - // if (maxTimeReached) { - // finishingMetadata.reasonTaskEnded = 'Max Time' - // } else if (numIncorrect >= maxIncorrect) { - // finishingMetadata.reasonTaskEnded = 'Max Incorrect Trials' - // } else { - // finishingMetadata.reasonTaskEnded = 'Completed Task' - // } - - // config.firekit.finishRun(finishingMetadata); - - if (!taskStore().demoMode) { + if (!taskStore().demoMode && config.firekit) { config.firekit.finishRun(); } }); @@ -154,7 +141,7 @@ export const initTrialSaving = (config: Record) => { // @ts-ignore jsPsych.opts.on_data_update = extend(jsPsych.opts.on_data_update, (data) => { - if (data.save_trial && !taskStore().demoMode) { + if (data.save_trial && !taskStore().demoMode && config.firekit) { // save_trial is a flag that indicates whether the trial should // be saved to Firestore. No point in writing it to the db. // creating a deep copy to prevent modifying of original data