diff --git a/.github/workflows/ci-pkg-cli.yml b/.github/workflows/ci-pkg-cli.yml new file mode 100644 index 000000000..ddfe50088 --- /dev/null +++ b/.github/workflows/ci-pkg-cli.yml @@ -0,0 +1,102 @@ +name: ci-pkg-cli +permissions: + contents: read + +on: + pull_request: + paths: + - 'packages/cli/**' + - 'packages/common/**' + - 'packages/db/**' + - 'packages/common-ui/**' + - 'packages/courseware/**' + - 'packages/express/**' + - 'packages/standalone-ui/**' + - '.github/workflows/ci-pkg-cli.yml' + - 'package.json' + - 'yarn.lock' + +jobs: + cli-try-init-e2e: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Display submodule information + run: | + echo "Submodule information:" + git submodule status + echo "CouchDB snapshot details:" + cd test-couch && git log -1 --pretty=format:'%h - %s (%cr) <%an>' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'yarn' + + - name: Setup Docker + uses: docker/setup-buildx-action@v2 + + - name: Install Yarn + run: corepack enable + + - name: Install dependencies + run: yarn install + + - name: Build library packages + run: | + yarn build:lib + yarn workspace @vue-skuilder/mcp build + yarn workspace @vue-skuilder/express build + yarn workspace @vue-skuilder/cli build + + - name: Install Cypress + run: yarn workspace @vue-skuilder/cli cypress install + + - name: Start CouchDB + run: yarn couchdb:start + + - name: Run try:init to create test project + working-directory: packages/cli + run: yarn try:init + + - name: Start scaffolded app dev server and wait for services + working-directory: packages/cli/testproject + run: | + # Start the dev server in background + npm run dev & + # Wait for the webserver to be ready + npx wait-on http://localhost:5173 --timeout 60000 + + - name: Run E2E tests on scaffolded app + working-directory: packages/cli + run: yarn test:e2e:headless + + - name: Cleanup services + if: always() + run: | + # Clean up dev server + kill $(lsof -t -i:5173) || true + # Clean up CouchDB + yarn couchdb:stop + + - name: Upload screenshots on failure + uses: actions/upload-artifact@v4 + if: failure() + with: + name: cypress-screenshots + path: packages/cli/cypress/screenshots + retention-days: 7 + + - name: Upload videos + uses: actions/upload-artifact@v4 + if: always() + with: + name: cypress-videos + path: packages/cli/cypress/videos + retention-days: 7 diff --git a/packages/cli/cypress.config.js b/packages/cli/cypress.config.js new file mode 100644 index 000000000..b1c85ba97 --- /dev/null +++ b/packages/cli/cypress.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + baseUrl: 'http://localhost:5173', // Default Vite dev server port + supportFile: 'cypress/support/e2e.js', + specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', + setupNodeEvents(on, config) { + // implement node event listeners here + }, + }, + // Increase the default timeout for slower operations + defaultCommandTimeout: 10000, + // Viewport configuration + viewportWidth: 1280, + viewportHeight: 800, +}); diff --git a/packages/cli/cypress/e2e/scaffolded-app.cy.js b/packages/cli/cypress/e2e/scaffolded-app.cy.js new file mode 100644 index 000000000..345be4414 --- /dev/null +++ b/packages/cli/cypress/e2e/scaffolded-app.cy.js @@ -0,0 +1,32 @@ +// Test for CLI scaffolded application +describe('CLI Scaffolded App - Study View', () => { + it('should navigate to /study and render a card', () => { + // Visit the study page + cy.visit('/study'); + + // Wait for the application to load and render + // Check for the presence of a card view + // The cardView class is present on all rendered cards + cy.get('.cardView', { timeout: 15000 }) + .should('exist') + .and('be.visible'); + + // Additional validation: check that the card has a viewable data attribute + // This indicates it's a properly rendered card component + cy.get('[data-viewable]', { timeout: 15000 }) + .should('exist') + .and('be.visible'); + + // Verify that the card container (v-card) is present + cy.get('.v-card', { timeout: 15000 }) + .should('exist') + .and('be.visible'); + }); + + it('should load the home page successfully', () => { + cy.visit('/'); + + // Verify basic page load + cy.get('body').should('be.visible'); + }); +}); diff --git a/packages/cli/cypress/support/commands.js b/packages/cli/cypress/support/commands.js new file mode 100644 index 000000000..07164efbe --- /dev/null +++ b/packages/cli/cypress/support/commands.js @@ -0,0 +1,9 @@ +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** diff --git a/packages/cli/cypress/support/e2e.js b/packages/cli/cypress/support/e2e.js new file mode 100644 index 000000000..b0265634d --- /dev/null +++ b/packages/cli/cypress/support/e2e.js @@ -0,0 +1,17 @@ +// *********************************************************** +// This example support/e2e.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands'; diff --git a/packages/cli/package.json b/packages/cli/package.json index 1f1140b55..df0211778 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -26,7 +26,9 @@ "lint": "npx eslint .", "lint:fix": "npx eslint . --fix", "lint:check": "npx eslint . --max-warnings 0", - "try:init": "node dist/cli.js init testproject --dangerously-clobber --no-interactive --data-layer static --import-course-data --import-server-url http://localhost:5984 --import-username admin --import-password password --import-course-ids 2aeb8315ef78f3e89ca386992d00825b && cd testproject && npm i && npm install --save-dev @vue-skuilder/cli@file:.. && npm install @vue-skuilder/db@file:../../db @vue-skuilder/courseware@file:../../courseware @vue-skuilder/common-ui@file:../../common-ui @vue-skuilder/express@file:../../express" + "try:init": "node dist/cli.js init testproject --dangerously-clobber --no-interactive --data-layer static --import-course-data --import-server-url http://localhost:5984 --import-username admin --import-password password --import-course-ids 2aeb8315ef78f3e89ca386992d00825b && cd testproject && npm i && npm install --save-dev @vue-skuilder/cli@file:.. && npm install @vue-skuilder/db@file:../../db @vue-skuilder/courseware@file:../../courseware @vue-skuilder/common-ui@file:../../common-ui @vue-skuilder/express@file:../../express", + "test:e2e": "cypress open", + "test:e2e:headless": "cypress run" }, "keywords": [ "cli", @@ -64,9 +66,11 @@ "@types/serve-static": "^1.15.0", "@vitejs/plugin-vue": "^5.2.1", "@vue-skuilder/studio-ui": "workspace:*", + "cypress": "14.1.0", "typescript": "~5.7.2", "vite": "^6.0.9", - "vue-tsc": "^1.8.0" + "vue-tsc": "^1.8.0", + "wait-on": "8.0.2" }, "engines": { "node": ">=18.0.0" diff --git a/packages/cli/src/utils/pack-courses.ts b/packages/cli/src/utils/pack-courses.ts index a5b90a28e..e704d9bff 100644 --- a/packages/cli/src/utils/pack-courses.ts +++ b/packages/cli/src/utils/pack-courses.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import path from 'path'; +import { promises as fs } from 'fs'; export interface PackCoursesOptions { server: string; @@ -39,6 +40,38 @@ export async function packCourses(options: PackCoursesOptions): Promise { }); console.log(chalk.green(`✅ Successfully packed course: ${courseId}`)); + + // Create course-level skuilder.json for the packed course + const coursePath = path.join(outputDir, courseId); + const manifestPath = path.join(coursePath, 'manifest.json'); + + // Read the manifest to get course title + let courseTitle = courseId; + try { + const manifestContent = await fs.readFile(manifestPath, 'utf-8'); + const manifest = JSON.parse(manifestContent); + courseTitle = manifest.courseName || manifest.courseConfig?.name || courseId; + } catch (error) { + console.warn(chalk.yellow(`⚠️ Could not read manifest for course title, using courseId`)); + } + + // Create course-level skuilder.json + const courseSkuilderJson = { + name: `@skuilder/course-${courseId}`, + version: '1.0.0', + description: courseTitle, + content: { + type: 'static', + manifest: './manifest.json', + }, + }; + + await fs.writeFile( + path.join(coursePath, 'skuilder.json'), + JSON.stringify(courseSkuilderJson, null, 2) + ); + + console.log(chalk.gray(`📄 Created skuilder.json for course: ${courseId}`)); } catch (error: unknown) { console.error(chalk.red(`❌ Failed to pack course ${courseId}:`)); console.error(chalk.red(error instanceof Error ? error.message : String(error))); diff --git a/packages/cli/src/utils/template.ts b/packages/cli/src/utils/template.ts index 4589f8883..beecb3e6d 100644 --- a/packages/cli/src/utils/template.ts +++ b/packages/cli/src/utils/template.ts @@ -289,6 +289,35 @@ export async function generateSkuilderConfig( await fs.writeFile(configPath, JSON.stringify(skuilderConfig, null, 2)); + // For static data layer, create root skuilder.json manifest + if (config.dataLayerType === 'static' && outputPath && skuilderConfig.course) { + const publicPath = path.join(outputPath, 'public'); + await fs.mkdir(publicPath, { recursive: true }); + + // Determine course IDs to include in dependencies + const courseIds = config.importCourseIds && config.importCourseIds.length > 0 + ? config.importCourseIds + : [skuilderConfig.course]; + + // Create root skuilder.json with course dependencies + const rootManifest = { + name: `@skuilder/${config.projectName}`, + version: '1.0.0', + description: config.title, + dependencies: Object.fromEntries( + courseIds.map((courseId) => [ + `@skuilder/course-${courseId}`, + `/static-courses/${courseId}/skuilder.json`, + ]) + ), + }; + + await fs.writeFile( + path.join(publicPath, 'skuilder.json'), + JSON.stringify(rootManifest, null, 2) + ); + } + // For static data layer without imports, create empty course structure if ( config.dataLayerType === 'static' && @@ -393,6 +422,22 @@ async function createEmptyCourseStructure( await fs.writeFile(path.join(coursePath, 'manifest.json'), JSON.stringify(manifest, null, 2)); + // Create course-level skuilder.json (points to manifest.json) + const courseSkuilderJson = { + name: `@skuilder/course-${courseId}`, + version: '1.0.0', + description: title, + content: { + type: 'static', + manifest: './manifest.json', + }, + }; + + await fs.writeFile( + path.join(coursePath, 'skuilder.json'), + JSON.stringify(courseSkuilderJson, null, 2) + ); + // Create empty tags index await fs.writeFile( path.join(coursePath, 'indices', 'tags.json'), diff --git a/packages/db/src/impl/static/StaticDataLayerProvider.ts b/packages/db/src/impl/static/StaticDataLayerProvider.ts index 86bed82cd..2761b5e60 100644 --- a/packages/db/src/impl/static/StaticDataLayerProvider.ts +++ b/packages/db/src/impl/static/StaticDataLayerProvider.ts @@ -70,12 +70,18 @@ export class StaticDataLayerProvider implements DataLayerProvider { throw new Error(`Failed to fetch final content manifest for ${courseName} at ${finalManifestUrl}`); } const finalManifest = await finalManifestResponse.json(); - - this.manifests[courseName] = finalManifest; + + // Extract courseId from the manifest to use as the lookup key + const courseId = finalManifest.courseId || finalManifest.courseConfig?.courseID; + if (!courseId) { + throw new Error(`Course manifest for ${courseName} missing courseId`); + } + + this.manifests[courseId] = finalManifest; const unpacker = new StaticDataUnpacker(finalManifest, baseUrl); - this.courseUnpackers.set(courseName, unpacker); + this.courseUnpackers.set(courseId, unpacker); - logger.info(`[StaticDataLayerProvider] Successfully resolved and prepared course: ${courseName}`); + logger.info(`[StaticDataLayerProvider] Successfully resolved and prepared course: ${courseName} (courseId: ${courseId})`); } } catch (e) { logger.error(`[StaticDataLayerProvider] Failed to resolve dependency ${courseName}:`, e); diff --git a/packages/standalone-ui/src/main.ts b/packages/standalone-ui/src/main.ts index 3a47aba9b..c4104e6ba 100644 --- a/packages/standalone-ui/src/main.ts +++ b/packages/standalone-ui/src/main.ts @@ -42,32 +42,25 @@ import config from '../skuilder.config.json'; }; if (config.dataLayerType === 'static') { - // Load manifest for static mode - const courseId = config.course; - if (!courseId) { - throw new Error('Course ID required for static data layer'); - } - + // Load root skuilder.json manifest for static mode try { - const manifestResponse = await fetch(`/static-courses/${courseId}/manifest.json`); - if (!manifestResponse.ok) { + const rootManifestUrl = '/skuilder.json'; + const rootManifestResponse = await fetch(rootManifestUrl); + if (!rootManifestResponse.ok) { throw new Error( - `Failed to load manifest: ${manifestResponse.status} ${manifestResponse.statusText}` + `Failed to load root manifest: ${rootManifestResponse.status} ${rootManifestResponse.statusText}` ); } - const manifest = await manifestResponse.json(); - console.log(`Loaded manifest for course ${courseId}`); - console.log(JSON.stringify(manifest)); + const rootManifest = await rootManifestResponse.json(); + console.log('[DEBUG] Loaded root manifest:', rootManifest); dataLayerOptions = { - staticContentPath: '/static-courses', - manifests: { - [courseId]: manifest, - }, + rootManifest, + rootManifestUrl: new URL(rootManifestUrl, window.location.href).href, }; } catch (error) { - console.error('[DEBUG] Failed to load course manifest:', error); - throw new Error(`Could not load course manifest for ${courseId}: ${error}`); + console.error('[DEBUG] Failed to load root manifest:', error); + throw new Error(`Could not load root skuilder.json manifest: ${error}`); } } diff --git a/yarn.lock b/yarn.lock index 2ef51d840..93ddd0e13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6502,6 +6502,7 @@ __metadata: "@vue-skuilder/studio-ui": "workspace:*" chalk: "npm:^5.3.0" commander: "npm:^11.0.0" + cypress: "npm:14.1.0" fs-extra: "npm:^11.2.0" inquirer: "npm:^9.2.0" leveldown: "npm:^6.1.1" @@ -6514,6 +6515,7 @@ __metadata: vue-router: "npm:^4.2.0" vue-tsc: "npm:^1.8.0" vuetify: "npm:^3.7.0" + wait-on: "npm:8.0.2" bin: skuilder: ./dist/cli.js languageName: unknown