Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .github/workflows/ci-pkg-cli.yml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions packages/cli/cypress.config.js
Original file line number Diff line number Diff line change
@@ -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,
});
32 changes: 32 additions & 0 deletions packages/cli/cypress/e2e/scaffolded-app.cy.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
9 changes: 9 additions & 0 deletions packages/cli/cypress/support/commands.js
Original file line number Diff line number Diff line change
@@ -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
// ***********************************************
17 changes: 17 additions & 0 deletions packages/cli/cypress/support/e2e.js
Original file line number Diff line number Diff line change
@@ -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';
8 changes: 6 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/utils/pack-courses.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import chalk from 'chalk';
import path from 'path';
import { promises as fs } from 'fs';

export interface PackCoursesOptions {
server: string;
Expand Down Expand Up @@ -39,6 +40,38 @@ export async function packCourses(options: PackCoursesOptions): Promise<void> {
});

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)));
Expand Down
45 changes: 45 additions & 0 deletions packages/cli/src/utils/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down Expand Up @@ -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'),
Expand Down
14 changes: 10 additions & 4 deletions packages/db/src/impl/static/StaticDataLayerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
29 changes: 11 additions & 18 deletions packages/standalone-ui/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
}

Expand Down
2 changes: 2 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
Loading