diff --git a/.eslintignore b/.eslintignore index e98ca3a5..b2352cd3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,6 +3,7 @@ dist **/node_modules out +test-artifacts/ test-resources/ submodules/ .vscode-test/ diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index a531a6d0..96ff6d4d 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -34,7 +34,11 @@ jobs: npm run setup chmod -R +x cli cd .. - - name: Run tests + - name: Unit tests uses: GabrielBB/xvfb-action@v1.0 with: run: npm test + - name: Integration tests + uses: GabrielBB/xvfb-action@v1.0 + with: + run: npm run test-integration-only integration diff --git a/.gitignore b/.gitignore index bd2e80a2..056e2f63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Project files .vscode-test/ node_modules/ +test-artifacts/ test-resources/ .eslintcache vscode.proposed.d.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 7a033fc6..6fb2cbe5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,7 +24,19 @@ "--extensionDevelopmentPath=${workspaceFolder}", "--extensionTestsPath=${workspaceFolder}/bin/testLoader" ], - "outFiles": ["${workspaceFolder}/out/test/**/*.js"], + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "preLaunchTask": "webpackBuild" + }, + { + "name": "Integration tests", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/bin/integrationLoader" + ], + "outFiles": ["${workspaceFolder}/dist/**/*.js"], "preLaunchTask": "webpackBuild" } ] diff --git a/.vscode/settings.json b/.vscode/settings.json index 76586405..3193b026 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,7 @@ "search.exclude": { "**/out": true }, + "files.eol": "\n", // Turn off tsc task auto detection since we have the necessary tasks as npm scripts "typescript.tsc.autoDetect": "off", "html.validate.scripts": false, diff --git a/.vscodeignore b/.vscodeignore index 7565d45c..7f76f6d7 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -18,7 +18,8 @@ types/** **/.gitmodules **/.eslintignore **/.eslintrc.json -**/dist/testBundle* +**dist/integration* +**/dist/test* **/tsconfig.json **/tsconfig.production.json **/tslint.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af4036c..54035ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [2.1.0] - 2021-07-05 + +#### Added + +- Exercise decorations: Show completed, expired, partially completed and missing in course workspace file tree. +- Automatically download old submission (disabled since version 2.0.0). + +#### Changed +- Bumped TMC-langs to version 0.21.0-beta-4. +- Moved Extension Settings behind VSCode Settings API. [Read more...](https://code.visualstudio.com/docs/getstarted/settings) +- Moved TMC folder selection to My Courses page. + +#### Removed +- Custom Settings webview. + +#### Security +- Updated dependencies. ## [2.0.3] - 2021-06-28 #### Fixed diff --git a/README.md b/README.md index c2e298d1..96aa2076 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Students of its various organizations can download, complete and return course e ## Prerequisites -* Visual Studio Code version 1.40.xx or above +* Visual Studio Code version 1.52.xx or above * [TestMyCode](https://tmc.mooc.fi/) account * Course-specific system environment diff --git a/backend/index.ts b/backend/index.ts index 491e8a45..f213aff8 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -27,6 +27,15 @@ import { const PORT = 4001; +interface DetailsForLangs { + exercises: Array<{ + id: number; + checksum: string; + course_name: string; + exercise_name: string; + }>; +} + const testOrganization = createOrganization({ information: "This is a test organization from a local development server.", name: "Test Organization", @@ -53,6 +62,7 @@ const submissions = [ exerciseName: passingExercise.name, id: 0, passed: true, + timestamp: new Date(2000, 1, 1), userId: 0, }), ]; @@ -111,6 +121,21 @@ app.get(`/api/v8/core/courses/${pythonCourse.id}`, (req, res: Response) => { + const rawIds = req.query.ids; + const ids = Array.isArray(rawIds) ? rawIds : [rawIds]; + const filtered = [passingExercise].filter((x) => ids.includes(x.id.toString())); + return res.json({ + exercises: filtered.map((x) => ({ + id: x.id, + checksum: x.checksum, + course_name: "python-course", + exercise_name: x.exercise_name, + })), + }); +}); + // getExerciseDetails(1) app.get(`/api/v8/core/exercises/${passingExercise.id}`, (req, res: Response) => res.json({ ...passingExercise, course_id: pythonCourse.id, course_name: pythonCourse.name }), @@ -134,6 +159,7 @@ app.post( exerciseName: passingExercise.name, id: passingExercise.id, passed: true, + timestamp: new Date(), userId: 0, }), ); diff --git a/backend/setup.ts b/backend/setup.ts index b2215d35..c64d0640 100644 --- a/backend/setup.ts +++ b/backend/setup.ts @@ -23,7 +23,7 @@ const copyTMCPythonModules = async (): Promise => { ncp(module, target, () => {}); }); console.log("Modules copied!"); - + await new Promise((res) => setTimeout(res, 1000)); await Promise.all( pythonExercises.map(async (exercise) => { console.log(`Creating download archive for ${exercise}`); diff --git a/backend/utils.ts b/backend/utils.ts index ee86d9e2..efcd327b 100644 --- a/backend/utils.ts +++ b/backend/utils.ts @@ -155,35 +155,39 @@ interface CreateOldSubmissionParams { exerciseName: string; id: number; passed: boolean; + timestamp: Date; userId: number; } -const createOldSubmission = (params: CreateOldSubmissionParams): OldSubmission => ({ - all_tests_passed: params.passed, - course_id: params.courseId, - created_at: "", - exercise_name: params.exerciseName, - id: params.id, - message_for_paste: "", - message_for_reviewer: "", - newer_submission_reviewed: false, - params_json: "", - paste_available: false, - paste_key: "", - points: "", - pretest_error: "", - processed: true, - processing_attempts_started_at: "", - processing_began_at: "", - processing_completed_at: "", - processing_tried_at: "", - requests_review: false, - requires_review: false, - review_dismissed: false, - reviewed: false, - times_sent_to_sandbox: 1, - user_id: 0, -}); +const createOldSubmission = (params: CreateOldSubmissionParams): OldSubmission => { + const timestamp = params.timestamp.toISOString(); + return { + all_tests_passed: params.passed, + course_id: params.courseId, + created_at: timestamp, + exercise_name: params.exerciseName, + id: params.id, + message_for_paste: "", + message_for_reviewer: "", + newer_submission_reviewed: false, + params_json: "", + paste_available: false, + paste_key: "", + points: "", + pretest_error: "", + processed: true, + processing_attempts_started_at: timestamp, + processing_began_at: timestamp, + processing_completed_at: timestamp, + processing_tried_at: timestamp, + requests_review: false, + requires_review: false, + review_dismissed: false, + reviewed: false, + times_sent_to_sandbox: 1, + user_id: 0, + }; +}; const respondWithFile = (res: Response, file: string): void => res.sendFile(file, (error) => { diff --git a/bin/integrationLoader.js b/bin/integrationLoader.js new file mode 100644 index 00000000..ae5de42c --- /dev/null +++ b/bin/integrationLoader.js @@ -0,0 +1,31 @@ +//@ts-check + +const Mocha = require("mocha"); +const path = require("path"); + +function run() { + // Create the mocha test + const mocha = new Mocha({ + ui: "tdd", + color: true, + }); + + return new Promise((c, e) => { + mocha.addFile(path.resolve(__dirname, "..", "dist", "integration.spec.js")); + + try { + // Run the mocha test + mocha.run((failures) => { + if (failures > 0) { + e(new Error(`${failures} tests failed.`)); + } else { + c(); + } + }); + } catch (err) { + e(err); + } + }); +} + +exports.run = run; diff --git a/bin/runIntegration.js b/bin/runIntegration.js new file mode 100644 index 00000000..edd8c4c2 --- /dev/null +++ b/bin/runIntegration.js @@ -0,0 +1,33 @@ +//@ts-check + +const path = require("path"); +const runTests = require("vscode-test").runTests; + +async function main() { + let exitCode = 0; + /**@type {import("child_process").ChildProcess} */ + try { + const platform = + process.platform === "win32" && process.arch === "x64" + ? "win32-x64-archive" + : undefined; + + // The folder containing the Extension Manifest package.json + // Passed to `--extensionDevelopmentPath` + const extensionDevelopmentPath = path.resolve(__dirname, ".."); + + // The path to test runner + // Passed to --extensionTestsPath + const extensionTestsPath = path.resolve(__dirname, "integrationLoader"); + + // Download VS Code, unzip it and run the integration test + await runTests({ extensionDevelopmentPath, extensionTestsPath, platform }); + } catch (err) { + console.error("Failed to run tests"); + exitCode = 1; + } + + process.exit(exitCode); +} + +main(); diff --git a/bin/runTests.js b/bin/runTests.js index d80b2bdb..1375e360 100644 --- a/bin/runTests.js +++ b/bin/runTests.js @@ -1,42 +1,12 @@ //@ts-check -const cp = require("child_process"); const path = require("path"); -const kill = require("tree-kill"); const runTests = require("vscode-test").runTests; -/**@returns {Promise} */ -async function startServer() { - let ready = false; - console.log(path.join(__dirname, "..", "backend")); - const server = cp.spawn("npm", ["start"], { - cwd: path.join(__dirname, "..", "backend"), - shell: "bash", - }); - server.stdout.on("data", (chunk) => { - if (chunk.toString().startsWith("Server listening to")) { - ready = true; - } - }); - - const timeout = setTimeout(() => { - throw new Error("Failed to start server"); - }, 10000); - - while (!ready) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - - clearTimeout(timeout); - return server; -} - async function main() { let exitCode = 0; /**@type {import("child_process").ChildProcess} */ - let backend; try { - backend = await startServer(); const platform = process.platform === "win32" && process.arch === "x64" ? "win32-x64-archive" @@ -55,10 +25,9 @@ async function main() { } catch (err) { console.error("Failed to run tests"); exitCode = 1; - } finally { - kill(backend.pid); - process.exit(exitCode); } + + process.exit(exitCode); } main(); diff --git a/bin/testLoader.js b/bin/testLoader.js index 431783e4..6c73bfce 100644 --- a/bin/testLoader.js +++ b/bin/testLoader.js @@ -1,3 +1,5 @@ +//@ts-check + const Mocha = require("mocha"); const path = require("path"); diff --git a/bin/validateRelease.sh b/bin/validateRelease.sh index 40ec9529..272c5e7f 100755 --- a/bin/validateRelease.sh +++ b/bin/validateRelease.sh @@ -22,7 +22,7 @@ fi packageLockVersion=`grep -Eo '"version":.+$' package-lock.json` if [[ ! $packageLockVersion =~ '"version": "'$tagVersion'",' ]] then - echo "Error: The version in package-lock.json doesn't match with the tag. Did you forget to run npm install?" + echo "Error: The version in package-lock.json doesn't match with the tag." exitCode=1 fi @@ -35,4 +35,12 @@ then exitCode=1 fi +# All configured Langs versions should exist on the download server. +node ./bin/verifyThatLangsBuildsExist.js +if [ $? != 0 ] +then + echo "Error: Failed to verify that all Langs builds exist." + exitCode=1 +fi + exit $exitCode diff --git a/bin/verifyThatLangsBuildsExist.js b/bin/verifyThatLangsBuildsExist.js new file mode 100644 index 00000000..c844d44d --- /dev/null +++ b/bin/verifyThatLangsBuildsExist.js @@ -0,0 +1,34 @@ +const fetch = require("node-fetch"); + +const config = require("../config"); +const getAllLangsCLIs = require("../src/utils/env").getAllLangsCLIs; + +const TMC_LANGS_DL_URL = config.productionApi.__TMC_LANGS_DL_URL__.replace(/"/g, ""); +const TMC_LANGS_VERSION = config.productionApi.__TMC_LANGS_VERSION__.replace(/"/g, ""); + +const langsBuildExists = (url) => + fetch.default(url, { method: "head" }).then((res) => res.status === 200); + +async function main() { + console.log("Verifying that all target TMC-langs builds exist..."); + let missingBuilds = false; + try { + const allCLIs = getAllLangsCLIs(TMC_LANGS_VERSION); + for (const cli of allCLIs) { + const url = TMC_LANGS_DL_URL + cli; + if (!(await langsBuildExists(url))) { + missingBuilds = true; + console.log("Failed to find", cli, "from", url); + } + } + if (missingBuilds) { + throw new Error("Some Langs builds were missing."); + } + } catch (e) { + console.error("Verification resulted in error:", e.message); + process.exit(1); + } + console.log("Looks good!"); +} + +main(); diff --git a/config.js b/config.js index 44e5ea07..e9af239c 100644 --- a/config.js +++ b/config.js @@ -3,7 +3,7 @@ const path = require("path"); -const TMC_LANGS_RUST_VERSION = "0.11.1"; +const TMC_LANGS_RUST_VERSION = "0.21.0-beta-4"; const localTMCServer = { __TMC_BACKEND__URL__: JSON.stringify("http://localhost:3000"), diff --git a/media/welcome_actions_jupyter.png b/media/welcome_actions_jupyter.png deleted file mode 100644 index 91d21178..00000000 Binary files a/media/welcome_actions_jupyter.png and /dev/null differ diff --git a/media/welcome_exercise_decorations.png b/media/welcome_exercise_decorations.png new file mode 100644 index 00000000..6d3cda10 Binary files /dev/null and b/media/welcome_exercise_decorations.png differ diff --git a/media/welcome_new_treeview.png b/media/welcome_new_treeview.png deleted file mode 100644 index 81d547d5..00000000 Binary files a/media/welcome_new_treeview.png and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 1ac219d9..fe9ed16b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "test-my-code", - "version": "2.0.3", + "version": "2.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index c942e425..45b88967 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "test-my-code", "displayName": "TestMyCode", "description": "TestMyCode extension for Visual Studio Code", - "version": "2.0.3", + "version": "2.1.0", "license": "MIT", "publisher": "moocfi", "repository": { @@ -76,6 +76,11 @@ "title": "Add New Course...", "category": "TestMyCode" }, + { + "command": "tmc.changeTmcDataPath", + "title": "Change TMC data path", + "category": "TestMyCode" + }, { "command": "tmc.cleanExercise", "title": "Clean Exercise", @@ -180,6 +185,47 @@ "title": "Wipe all extension data" } ], + "configuration": { + "title": "TestMyCode", + "properties": { + "testMyCode.downloadOldSubmission": { + "type": "boolean", + "default": true, + "description": "When downloading exercises, download your latest submission instead of the exercise template." + }, + "testMyCode.hideMetaFiles": { + "type": "boolean", + "default": true, + "description": "Hide exercise meta files that are not relevant for completing exercises." + }, + "testMyCode.insiderVersion": { + "type": "boolean", + "scope": "application", + "default": false, + "description": "Insider version to test new features for the TestMyCode extension." + }, + "testMyCode.logLevel": { + "type": "string", + "scope": "application", + "default": "errors", + "enum": [ + "none", + "errors", + "verbose" + ], + "enumDescriptions": [ + "No extension logging.", + "Log only warning and error messages.", + "Log info, warning and error messages." + ] + }, + "testMyCode.updateExercisesAutomatically": { + "type": "boolean", + "default": true, + "description": "Download exercise updates automatically." + } + } + }, "keybindings": [ { "command": "tmc.closeExercise", @@ -373,6 +419,8 @@ "ci:all": "npm ci && cd backend && npm ci", "pretest": "cross-env NODE_ENV=development BACKEND=mockBackend npm run webpack", "test": "node ./bin/runTests.js", + "test-integration": "npm pretest && node ./bin/runIntegration.js", + "test-integration-only": "node ./bin/runIntegration.js", "eslint-check": "eslint . --ext .js,.ts", "eslint": "eslint --fix . --ext .js,.ts", "lint-check": "npm run eslint-check && npm run prettier-check", diff --git a/src/actions/addNewCourse.ts b/src/actions/addNewCourse.ts new file mode 100644 index 00000000..8dd84540 --- /dev/null +++ b/src/actions/addNewCourse.ts @@ -0,0 +1,59 @@ +import { Result } from "ts-results"; + +import { LocalCourseData } from "../api/storage"; +import { Logger } from "../utils"; +import { combineApiExerciseData } from "../utils/apiData"; + +import { refreshLocalExercises } from "./refreshLocalExercises"; +import { ActionContext } from "./types"; +import { displayUserCourses } from "./webview"; + +/** + * Adds a new course to user's courses. + */ +export async function addNewCourse( + actionContext: ActionContext, + organization: string, + course: number, +): Promise> { + const { tmc, ui, userData, workspaceManager } = actionContext; + Logger.log("Adding new course"); + + const courseDataResult = await tmc.getCourseData(course); + if (courseDataResult.err) { + return courseDataResult; + } + const courseData = courseDataResult.val; + + let availablePoints = 0; + let awardedPoints = 0; + courseData.exercises.forEach((x) => { + availablePoints += x.available_points.length; + awardedPoints += x.awarded_points.length; + }); + + const localData: LocalCourseData = { + description: courseData.details.description || "", + exercises: combineApiExerciseData(courseData.details.exercises, courseData.exercises), + id: courseData.details.id, + name: courseData.details.name, + title: courseData.details.title, + organization: organization, + availablePoints: availablePoints, + awardedPoints: awardedPoints, + perhapsExamMode: courseData.settings.hide_submission_results, + newExercises: [], + notifyAfter: 0, + disabled: courseData.settings.disabled_status === "enabled" ? false : true, + materialUrl: courseData.settings.material_url, + }; + userData.addCourse(localData); + ui.treeDP.addChildWithId("myCourses", localData.id, localData.title, { + command: "tmc.courseDetails", + title: "Go To Course Details", + arguments: [localData.id], + }); + workspaceManager.createWorkspaceFile(courseData.details.name); + await displayUserCourses(actionContext); + return refreshLocalExercises(actionContext); +} diff --git a/src/actions/downloadNewExercisesForCourse.ts b/src/actions/downloadNewExercisesForCourse.ts index 3da46017..5a1fe02b 100644 --- a/src/actions/downloadNewExercisesForCourse.ts +++ b/src/actions/downloadNewExercisesForCourse.ts @@ -2,7 +2,7 @@ import { Ok, Result } from "ts-results"; import { Logger } from "../utils"; -import { downloadOrUpdateExercises } from "./downloadOrUpdateCourseExercises"; +import { downloadOrUpdateExercises } from "./downloadOrUpdateExercises"; import { refreshLocalExercises } from "./refreshLocalExercises"; import { ActionContext } from "./types"; diff --git a/src/actions/downloadOrUpdateCourseExercises.ts b/src/actions/downloadOrUpdateCourseExercises.ts deleted file mode 100644 index aeba1d7a..00000000 --- a/src/actions/downloadOrUpdateCourseExercises.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { concat, flatten, groupBy, partition } from "lodash"; -import * as pLimit from "p-limit"; -import { Ok, Result } from "ts-results"; - -import { WebviewMessage } from "../ui/types"; - -import { ActionContext } from "./types"; - -// Use this until using Langs version with file locks -const limit = pLimit(1); - -/** - * Downloads given exercises and opens them in TMC workspace. - * - * @param exerciseIds Exercises to download. - * @returns Exercise ids for successful downloads. - */ -export async function downloadOrUpdateExercises( - actionContext: ActionContext, - exerciseIds: number[], -): Promise> { - const { dialog, tmc, ui, userData } = actionContext; - - // TODO: How to download latest submission in new version? - const downloadResult = await dialog.progressNotification( - "Downloading exercises...", - (progress) => - limit(() => - tmc.downloadExercises(exerciseIds, (download) => { - progress.report(download); - ui.webview.postMessage({ - command: "exerciseStatusChange", - exerciseId: download.id, - status: "closed", - }); - }), - ), - ); - if (downloadResult.err) { - return downloadResult; - } - - // Some code still treats exercises by their ids, so return those instead. - // Uuunfortunately. - const successfulSlugs = concat(downloadResult.val.downloaded, downloadResult.val.skipped); - const successfulByCourse = groupBy(successfulSlugs, (x) => x["course-slug"]); - const successfulIdsByCourse = Object.keys(successfulByCourse).map((course) => { - const downloadedSlugs = new Set(successfulByCourse[course].map((x) => x["exercise-slug"])); - return userData - .getCourseByName(course) - .exercises.filter((x) => downloadedSlugs.has(x.name)) - .map((x) => x.id); - }); - const successfulIds = new Set(flatten(successfulIdsByCourse)); - const [successful, failed] = partition(exerciseIds, (x) => successfulIds.has(x)); - - ui.webview.postMessage( - ...successful.map((x) => ({ - command: "exerciseStatusChange", - exerciseId: x, - status: "opened", - })), - ...failed.map((x) => ({ - command: "exerciseStatusChange", - exerciseId: x, - status: "closed", - })), - ); - - return Ok({ successful, failed }); -} diff --git a/src/actions/downloadOrUpdateExercises.ts b/src/actions/downloadOrUpdateExercises.ts new file mode 100644 index 00000000..96b32d7e --- /dev/null +++ b/src/actions/downloadOrUpdateExercises.ts @@ -0,0 +1,89 @@ +import * as pLimit from "p-limit"; +import { Ok, Result } from "ts-results"; + +import { ExerciseStatus, WebviewMessage } from "../ui/types"; +import TmcWebview from "../ui/webview"; +import { Logger } from "../utils"; + +import { ActionContext } from "./types"; + +// Use this until using Langs version with file locks +const limit = pLimit(1); + +interface DownloadResults { + successful: number[]; + failed: number[]; +} + +/** + * Downloads given exercises and opens them in TMC workspace. + * + * @param exerciseIds Exercises to download. + * @returns Exercise ids for successful downloads. + */ +export async function downloadOrUpdateExercises( + actionContext: ActionContext, + exerciseIds: number[], +): Promise> { + const { dialog, settings, tmc, ui } = actionContext; + if (exerciseIds.length === 0) { + return Ok({ successful: [], failed: [] }); + } + + ui.webview.postMessage(...exerciseIds.map((x) => wrapToMessage(x, "downloading"))); + const statuses = new Map(exerciseIds.map((x) => [x, "downloadFailed"])); + + const downloadTemplate = !settings.getDownloadOldSubmission(); + const downloadResult = await dialog.progressNotification( + "Downloading exercises...", + (progress) => + limit(() => + tmc.downloadExercises(exerciseIds, downloadTemplate, (download) => { + progress.report(download); + statuses.set(download.id, "closed"); + ui.webview.postMessage(wrapToMessage(download.id, "closed")); + }), + ), + ); + if (downloadResult.err) { + postMessages(ui.webview, statuses); + return downloadResult; + } + + const { downloaded, failed, skipped } = downloadResult.val; + skipped.length > 0 && Logger.warn(`${skipped.length} downloads were skipped.`); + downloaded.forEach((x) => statuses.set(x.id, "opened")); + skipped.forEach((x) => statuses.set(x.id, "closed")); + failed?.forEach(([exercise, reason]) => { + Logger.error(`Failed to download exercise ${exercise["exercise-slug"]}: ${reason}`); + statuses.set(exercise.id, "downloadFailed"); + }); + postMessages(ui.webview, statuses); + + return Ok(sortResults(statuses)); +} + +function postMessages(webview: TmcWebview, statuses: Map): void { + webview.postMessage(...Array.from(statuses.entries()).map(([id, s]) => wrapToMessage(id, s))); +} + +function wrapToMessage(exerciseId: number, status: ExerciseStatus): WebviewMessage { + return { + command: "exerciseStatusChange", + exerciseId, + status, + }; +} + +function sortResults(statuses: Map): DownloadResults { + const successful: number[] = []; + const failed: number[] = []; + statuses.forEach((status, id) => { + if (status !== "downloadFailed") { + successful.push(id); + } else { + failed.push(id); + } + }); + return { successful, failed }; +} diff --git a/src/actions/index.ts b/src/actions/index.ts index e44e8b79..f229ba2a 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,8 +1,11 @@ +export * from "./addNewCourse"; export * from "./checkForExerciseUpdates"; export * from "./downloadNewExercisesForCourse"; -export * from "./downloadOrUpdateCourseExercises"; +export * from "./downloadOrUpdateExercises"; export * from "./moveExtensionDataPath"; export * from "./refreshLocalExercises"; +export * from "./selectOrganizationAndCourse"; export * from "./user"; +export * from "./updateCourse"; export * from "./webview"; export * from "./workspace"; diff --git a/src/actions/selectOrganizationAndCourse.ts b/src/actions/selectOrganizationAndCourse.ts new file mode 100644 index 00000000..868f0bea --- /dev/null +++ b/src/actions/selectOrganizationAndCourse.ts @@ -0,0 +1,120 @@ +import { Err, Ok, Result } from "ts-results"; + +import TemporaryWebview from "../ui/temporaryWebview"; +import { Logger } from "../utils"; + +import { ActionContext } from "./types"; + +/** + * Creates a new temporary webview where user can select an organization and a course. + */ +export async function selectOrganizationAndCourse( + actionContext: ActionContext, +): Promise> { + const { resources, ui } = actionContext; + + const tempView = new TemporaryWebview(resources, ui); + + let organizationSlug: string | undefined; + let courseId: number | undefined; + + while (!(organizationSlug && courseId)) { + const orgResult = await selectOrganization(actionContext, tempView); + if (orgResult.err) { + tempView.dispose(); + return orgResult; + } + Logger.log(`Organization slug ${orgResult.val} selected`); + organizationSlug = orgResult.val; + const courseResult = await selectCourse(actionContext, organizationSlug, tempView); + if (courseResult.err) { + tempView.dispose(); + return courseResult; + } + if (courseResult.val.changeOrg) { + continue; + } + courseId = courseResult.val.course; + } + Logger.log(`Course with id ${courseId} selected`); + tempView.dispose(); + return new Ok({ organization: organizationSlug, course: courseId }); +} + +async function selectOrganization( + actionContext: ActionContext, + webview?: TemporaryWebview, +): Promise> { + const { tmc, resources, ui } = actionContext; + + const result = await tmc.getOrganizations(); + if (result.err) { + return result; + } + const organizations = result.val.sort((org1, org2) => org1.name.localeCompare(org2.name)); + const pinned = organizations.filter((organization) => organization.pinned); + const data = { organizations, pinned }; + let slug: string | undefined; + + await new Promise((resolve) => { + const temp = webview || new TemporaryWebview(resources, ui); + temp.setContent({ + title: "Select organization", + template: { templateName: "organization", ...data }, + messageHandler: (msg: { type?: string; slug?: string }) => { + if (msg.type !== "setOrganization") { + return; + } + slug = msg.slug; + if (!webview) { + temp.dispose(); + } + resolve(); + }, + }); + }); + if (!slug) { + return new Err(new Error("Couldn't get organization")); + } + return new Ok(slug); +} + +async function selectCourse( + actionContext: ActionContext, + orgSlug: string, + webview?: TemporaryWebview, +): Promise> { + const { tmc, resources, ui } = actionContext; + const result = await tmc.getCourses(orgSlug); + + if (result.err) { + return result; + } + const courses = result.val.sort((course1, course2) => course1.name.localeCompare(course2.name)); + const organization = (await tmc.getOrganization(orgSlug)).unwrap(); + const data = { courses, organization }; + let changeOrg = false; + let course: number | undefined; + + await new Promise((resolve) => { + const temp = webview || new TemporaryWebview(resources, ui); + temp.setContent({ + title: "Select course", + template: { templateName: "course", ...data }, + messageHandler: (msg: { type?: string; id?: number }) => { + if (msg.type === "setCourse") { + course = msg.id; + } else if (msg.type === "changeOrg") { + changeOrg = true; + } else { + return; + } + if (!webview) { + temp.dispose(); + } + resolve(); + }, + }); + }); + return new Ok({ changeOrg, course }); +} diff --git a/src/actions/types.ts b/src/actions/types.ts index 013fdf3e..772d85ef 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -1,4 +1,5 @@ import Dialog from "../api/dialog"; +import ExerciseDecorationProvider from "../api/exerciseDecorationProvider"; import TMC from "../api/tmc"; import WorkspaceManager from "../api/workspaceManager"; import Resources from "../config/resources"; @@ -10,6 +11,7 @@ import UI from "../ui/ui"; export type ActionContext = { dialog: Dialog; + exerciseDecorationProvider: ExerciseDecorationProvider; resources: Resources; settings: Settings; temporaryWebviewProvider: TemporaryWebviewProvider; diff --git a/src/actions/updateCourse.ts b/src/actions/updateCourse.ts new file mode 100644 index 00000000..f836e07c --- /dev/null +++ b/src/actions/updateCourse.ts @@ -0,0 +1,95 @@ +import { Ok, Result } from "ts-results"; + +import { ConnectionError, ForbiddenError } from "../errors"; +import { Logger } from "../utils"; +import { combineApiExerciseData } from "../utils/apiData"; + +import { ActionContext } from "./types"; + +/** + * Updates the given course by re-fetching all data from the server. Handles authorization and + * connection errors as successful operations where the data was not actually updated. + * + * @param courseId ID of the course to update. + * @returns Boolean value representing whether the data from server was successfully received. + */ +export async function updateCourse( + actionContext: ActionContext, + courseId: number, +): Promise> { + const { exerciseDecorationProvider, tmc, ui, userData, workspaceManager } = actionContext; + const postMessage = (courseId: number, disabled: boolean, exerciseIds: number[]): void => { + ui.webview.postMessage( + { + command: "setNewExercises", + courseId, + exerciseIds, + }, + { + command: "setCourseDisabledStatus", + courseId, + disabled, + }, + ); + }; + const courseData = userData.getCourse(courseId); + const updateResult = await tmc.getCourseData(courseId, { forceRefresh: true }); + if (updateResult.err) { + if (updateResult.val instanceof ForbiddenError) { + if (!courseData.disabled) { + Logger.warn( + `Failed to access information for course ${courseData.name}. Marking as disabled.`, + ); + const course = userData.getCourse(courseId); + await userData.updateCourse({ ...course, disabled: true }); + postMessage(course.id, true, []); + } else { + Logger.warn( + `ForbiddenError above probably caused by course still being disabled ${courseData.name}`, + ); + postMessage(courseData.id, true, []); + } + return Ok(false); + } else if (updateResult.val instanceof ConnectionError) { + Logger.warn("Failed to fetch data from TMC servers, data not updated."); + return Ok(false); + } else { + return updateResult; + } + } + + const { details, exercises, settings } = updateResult.val; + const [availablePoints, awardedPoints] = exercises.reduce( + (a, b) => [a[0] + b.available_points.length, a[1] + b.awarded_points.length], + [0, 0], + ); + + await userData.updateCourse({ + ...courseData, + availablePoints, + awardedPoints, + description: details.description || "", + disabled: settings.disabled_status !== "enabled", + materialUrl: settings.material_url, + perhapsExamMode: settings.hide_submission_results, + }); + + const updateExercisesResult = await userData.updateExercises( + courseId, + combineApiExerciseData(details.exercises, exercises), + ); + if (updateExercisesResult.err) { + return updateExercisesResult; + } + + if (courseData.name === workspaceManager.activeCourse) { + exerciseDecorationProvider.updateDecorationsForExercises( + ...workspaceManager.getExercisesByCourseSlug(courseData.name), + ); + } + + const course = userData.getCourse(courseId); + postMessage(course.id, course.disabled, course.newExercises); + + return Ok(true); +} diff --git a/src/actions/user.ts b/src/actions/user.ts index c3d10eb0..ce58e4e4 100644 --- a/src/actions/user.ts +++ b/src/actions/user.ts @@ -4,7 +4,6 @@ * ------------------------------------------------------------------------------------------------- */ -import du = require("du"); import * as fs from "fs-extra"; import * as _ from "lodash"; import { Err, Ok, Result } from "ts-results"; @@ -14,20 +13,14 @@ import { LocalCourseData } from "../api/storage"; import { SubmissionFeedback } from "../api/types"; import { WorkspaceExercise } from "../api/workspaceManager"; import { EXAM_TEST_RESULT, NOTIFICATION_DELAY } from "../config/constants"; -import { BottleneckError, ConnectionError, ForbiddenError } from "../errors"; +import { BottleneckError } from "../errors"; import { TestResultData } from "../ui/types"; -import { - formatSizeInBytes, - isCorrectWorkspaceOpen, - Logger, - parseFeedbackQuestion, -} from "../utils/"; +import { Logger, parseFeedbackQuestion } from "../utils/"; import { getActiveEditorExecutablePath } from "../window"; import { downloadNewExercisesForCourse } from "./downloadNewExercisesForCourse"; -import { refreshLocalExercises } from "./refreshLocalExercises"; import { ActionContext, FeedbackQuestion } from "./types"; -import { displayUserCourses, selectOrganizationAndCourse } from "./webview"; +import { updateCourse } from "./updateCourse"; /** * Authenticates and logs the user in if credentials are correct. @@ -190,7 +183,8 @@ export async function submitExercise( actionContext: ActionContext, exercise: WorkspaceExercise, ): Promise> { - const { dialog, temporaryWebviewProvider, tmc, userData } = actionContext; + const { dialog, exerciseDecorationProvider, temporaryWebviewProvider, tmc, userData } = + actionContext; Logger.log(`Submitting exercise ${exercise.exerciseSlug} to server`); const course = userData.getCourseByName(exercise.courseSlug); @@ -294,6 +288,9 @@ export async function submitExercise( let feedbackQuestions: FeedbackQuestion[] = []; if (statusData.status === "ok" && statusData.all_tests_passed) { + userData.setExerciseAsPassed(exercise.courseSlug, exercise.exerciseSlug).then(() => { + exerciseDecorationProvider.updateDecorationsForExercises(exercise); + }); if (statusData.feedback_questions) { feedbackQuestions = parseFeedbackQuestion(statusData.feedback_questions); } @@ -410,7 +407,7 @@ export async function openWorkspace(actionContext: ActionContext, name: string): Logger.log(`Current workspace: ${currentWorkspaceFile?.fsPath}`); Logger.log(`TMC workspace: ${tmcWorkspaceFile}`); - if (!isCorrectWorkspaceOpen(resources, name)) { + if (!(currentWorkspaceFile?.toString() === tmcWorkspaceFile.toString())) { if ( !currentWorkspaceFile || (await dialog.confirmation( @@ -444,115 +441,6 @@ export async function openWorkspace(actionContext: ActionContext, name: string): } } -/** - * Settings webview - */ -export async function openSettings(actionContext: ActionContext): Promise { - const { dialog, ui, resources, settings } = actionContext; - Logger.log("Display extension settings"); - const extensionSettingsResult = await settings.getExtensionSettings(); - if (extensionSettingsResult.err) { - dialog.errorNotification("Failed to fetch settings.", extensionSettingsResult.val); - return; - } - - const extensionSettings = extensionSettingsResult.val; - ui.webview.setContentFromTemplate({ templateName: "settings" }, true, [ - { - command: "setBooleanSetting", - setting: "downloadOldSubmission", - enabled: extensionSettings.hideMetaFiles, - }, - { - command: "setBooleanSetting", - setting: "hideMetaFiles", - enabled: extensionSettings.hideMetaFiles, - }, - { command: "setBooleanSetting", setting: "insider", enabled: settings.isInsider() }, - { - command: "setBooleanSetting", - setting: "updateExercisesAutomatically", - enabled: settings.getAutomaticallyUpdateExercises(), - }, - { command: "setLogLevel", level: settings.getLogLevel() }, - { - command: "setTmcDataFolder", - diskSize: formatSizeInBytes(await du(resources.projectsDirectory)), - path: resources.projectsDirectory, - }, - ]); -} - -interface NewCourseOptions { - organization?: string; - course?: number; -} -/** - * Adds a new course to user's courses. - */ -export async function addNewCourse( - actionContext: ActionContext, - options?: NewCourseOptions, -): Promise> { - const { tmc, ui, userData, workspaceManager } = actionContext; - Logger.log("Adding new course"); - let organization = options?.organization; - let course = options?.course; - - if (!organization || !course) { - const orgAndCourse = await selectOrganizationAndCourse(actionContext); - if (orgAndCourse.err) { - return orgAndCourse; - } - organization = orgAndCourse.val.organization; - course = orgAndCourse.val.course; - } - - const courseDataResult = await tmc.getCourseData(course); - if (courseDataResult.err) { - return courseDataResult; - } - const courseData = courseDataResult.val; - - let availablePoints = 0; - let awardedPoints = 0; - courseData.exercises.forEach((x) => { - availablePoints += x.available_points.length; - awardedPoints += x.awarded_points.length; - }); - - const localData: LocalCourseData = { - description: courseData.details.description || "", - exercises: courseData.details.exercises.map((e) => ({ - id: e.id, - name: e.name, - deadline: e.deadline, - passed: e.completed, - softDeadline: e.soft_deadline, - })), - id: courseData.details.id, - name: courseData.details.name, - title: courseData.details.title, - organization: organization, - availablePoints: availablePoints, - awardedPoints: awardedPoints, - perhapsExamMode: courseData.settings.hide_submission_results, - newExercises: [], - notifyAfter: 0, - disabled: courseData.settings.disabled_status === "enabled" ? false : true, - materialUrl: courseData.settings.material_url, - }; - userData.addCourse(localData); - ui.treeDP.addChildWithId("myCourses", localData.id, localData.title, { - command: "tmc.courseDetails", - title: "Go To Course Details", - arguments: [localData.id], - }); - workspaceManager.createWorkspaceFile(courseData.details.name); - await displayUserCourses(actionContext); - return refreshLocalExercises(actionContext); -} - /** * Removes given course from UserData and removes its associated files. However, doesn't remove any * exercises that are on disk. @@ -577,91 +465,3 @@ export async function removeCourse(actionContext: ActionContext, id: number): Pr await vscode.commands.executeCommand("workbench.action.closeFolder"); } } - -/** - * Updates the given course by re-fetching all data from the server. Handles authorization and - * connection errors as successful operations where the data was not actually updated. - * - * @param courseId ID of the course to update. - * @returns Boolean value representing whether the data from server was successfully received. - */ -export async function updateCourse( - actionContext: ActionContext, - courseId: number, -): Promise> { - const { tmc, ui, userData } = actionContext; - const postMessage = (courseId: number, disabled: boolean, exerciseIds: number[]): void => { - ui.webview.postMessage( - { - command: "setNewExercises", - courseId, - exerciseIds, - }, - { - command: "setCourseDisabledStatus", - courseId, - disabled, - }, - ); - }; - const courseData = userData.getCourse(courseId); - const updateResult = await tmc.getCourseData(courseId, { forceRefresh: true }); - if (updateResult.err) { - if (updateResult.val instanceof ForbiddenError) { - if (!courseData.disabled) { - Logger.warn( - `Failed to access information for course ${courseData.name}. Marking as disabled.`, - ); - const course = userData.getCourse(courseId); - await userData.updateCourse({ ...course, disabled: true }); - postMessage(course.id, true, []); - } else { - Logger.warn( - `ForbiddenError above probably caused by course still being disabled ${courseData.name}`, - ); - postMessage(courseData.id, true, []); - } - return Ok(false); - } else if (updateResult.val instanceof ConnectionError) { - Logger.warn("Failed to fetch data from TMC servers, data not updated."); - return Ok(false); - } else { - return updateResult; - } - } - - const { details, exercises, settings } = updateResult.val; - const [availablePoints, awardedPoints] = exercises.reduce( - (a, b) => [a[0] + b.available_points.length, a[1] + b.awarded_points.length], - [0, 0], - ); - - await userData.updateCourse({ - ...courseData, - availablePoints, - awardedPoints, - description: details.description || "", - disabled: settings.disabled_status !== "enabled", - materialUrl: settings.material_url, - perhapsExamMode: settings.hide_submission_results, - }); - - const updateExercisesResult = await userData.updateExercises( - courseId, - details.exercises.map((x) => ({ - id: x.id, - name: x.name, - deadline: x.deadline, - passed: x.completed, - softDeadline: x.soft_deadline, - })), - ); - if (updateExercisesResult.err) { - return updateExercisesResult; - } - - const course = userData.getCourse(courseId); - postMessage(course.id, course.disabled, course.newExercises); - - return Ok(true); -} diff --git a/src/actions/webview.ts b/src/actions/webview.ts index d4ab1e87..665dbc6f 100644 --- a/src/actions/webview.ts +++ b/src/actions/webview.ts @@ -4,14 +4,19 @@ * ------------------------------------------------------------------------------------------------- */ -import { Err, Ok, Result } from "ts-results"; +import du = require("du"); import { Exercise } from "../api/types"; import { ExerciseStatus } from "../api/workspaceManager"; -import TemporaryWebview from "../ui/temporaryWebview"; import * as UITypes from "../ui/types"; import { WebviewMessage } from "../ui/types"; -import { dateToString, Logger, parseDate, parseNextDeadlineAfter } from "../utils/"; +import { + dateToString, + formatSizeInBytes, + Logger, + parseDate, + parseNextDeadlineAfter, +} from "../utils/"; import { checkForExerciseUpdates } from "./checkForExerciseUpdates"; import { ActionContext } from "./types"; @@ -20,7 +25,7 @@ import { ActionContext } from "./types"; * Displays a summary page of user's courses. */ export async function displayUserCourses(actionContext: ActionContext): Promise { - const { userData, tmc, ui } = actionContext; + const { userData, tmc, ui, resources } = actionContext; Logger.log("Displaying My Courses view"); const courses = userData.getCourses(); @@ -38,6 +43,11 @@ export async function displayUserCourses(actionContext: ActionContext): Promise< ui.webview.setContentFromTemplate({ templateName: "my-courses", courses }, false, [ ...newExercisesCourses, ...disabledStatusCourses, + { + command: "setTmcDataFolder", + diskSize: formatSizeInBytes(await du(resources.projectsDirectory)), + path: resources.projectsDirectory, + }, ]); const now = new Date(); @@ -180,123 +190,3 @@ export async function displayLocalCourseDetails( Logger.warn("Failed to check for exercise updates"); } } - -/** - * Lets the user select a course - */ -export async function selectCourse( - actionContext: ActionContext, - orgSlug: string, - webview?: TemporaryWebview, -): Promise> { - const { tmc, resources, ui } = actionContext; - const result = await tmc.getCourses(orgSlug); - - if (result.err) { - return result; - } - const courses = result.val.sort((course1, course2) => course1.name.localeCompare(course2.name)); - const organization = (await tmc.getOrganization(orgSlug)).unwrap(); - const data = { courses, organization }; - let changeOrg = false; - let course: number | undefined; - - await new Promise((resolve) => { - const temp = webview || new TemporaryWebview(resources, ui); - temp.setContent({ - title: "Select course", - template: { templateName: "course", ...data }, - messageHandler: (msg: { type?: string; id?: number }) => { - if (msg.type === "setCourse") { - course = msg.id; - } else if (msg.type === "changeOrg") { - changeOrg = true; - } else { - return; - } - if (!webview) { - temp.dispose(); - } - resolve(); - }, - }); - }); - return new Ok({ changeOrg, course }); -} - -/** - * Lets the user select an organization - */ -export async function selectOrganization( - actionContext: ActionContext, - webview?: TemporaryWebview, -): Promise> { - const { tmc, resources, ui } = actionContext; - - const result = await tmc.getOrganizations(); - if (result.err) { - return result; - } - const organizations = result.val.sort((org1, org2) => org1.name.localeCompare(org2.name)); - const pinned = organizations.filter((organization) => organization.pinned); - const data = { organizations, pinned }; - let slug: string | undefined; - - await new Promise((resolve) => { - const temp = webview || new TemporaryWebview(resources, ui); - temp.setContent({ - title: "Select organization", - template: { templateName: "organization", ...data }, - messageHandler: (msg: { type?: string; slug?: string }) => { - if (msg.type !== "setOrganization") { - return; - } - slug = msg.slug; - if (!webview) { - temp.dispose(); - } - resolve(); - }, - }); - }); - if (!slug) { - return new Err(new Error("Couldn't get organization")); - } - return new Ok(slug); -} - -/** - * Creates a new temporary webview where user can select an organization and a course. - */ -export async function selectOrganizationAndCourse( - actionContext: ActionContext, -): Promise> { - const { resources, ui } = actionContext; - - const tempView = new TemporaryWebview(resources, ui); - - let organizationSlug: string | undefined; - let courseId: number | undefined; - - while (!(organizationSlug && courseId)) { - const orgResult = await selectOrganization(actionContext, tempView); - if (orgResult.err) { - tempView.dispose(); - return orgResult; - } - Logger.log(`Organization slug ${orgResult.val} selected`); - organizationSlug = orgResult.val; - const courseResult = await selectCourse(actionContext, organizationSlug, tempView); - if (courseResult.err) { - tempView.dispose(); - return courseResult; - } - if (courseResult.val.changeOrg) { - continue; - } - courseId = courseResult.val.course; - } - Logger.log(`Course with id ${courseId} selected`); - tempView.dispose(); - return new Ok({ organization: organizationSlug, course: courseId }); -} diff --git a/src/actions/workspace.ts b/src/actions/workspace.ts index e3dd1d6a..4720c25b 100644 --- a/src/actions/workspace.ts +++ b/src/actions/workspace.ts @@ -38,7 +38,7 @@ export async function openExercises( .map((x) => x.exerciseSlug); const settingsResult = await tmc.setSetting( `closed-exercises-for:${courseName}`, - JSON.stringify(closedExerciseNames), + closedExerciseNames, ); if (settingsResult.err) { return settingsResult; @@ -81,7 +81,7 @@ export async function closeExercises( .map((x) => x.exerciseSlug); const settingsResult = await tmc.setSetting( `closed-exercises-for:${courseName}`, - JSON.stringify(closedExerciseNames), + closedExerciseNames, ); if (settingsResult.err) { return settingsResult; diff --git a/src/api/exerciseDecorationProvider.ts b/src/api/exerciseDecorationProvider.ts new file mode 100644 index 00000000..3284f6bf --- /dev/null +++ b/src/api/exerciseDecorationProvider.ts @@ -0,0 +1,88 @@ +import * as vscode from "vscode"; + +import WorkspaceManager, { WorkspaceExercise } from "../api/workspaceManager"; +import { UserData } from "../config/userdata"; + +/** + * Class that adds decorations like completion icons for exercises. + */ +export default class ExerciseDecorationProvider + implements vscode.Disposable, vscode.FileDecorationProvider +{ + public onDidChangeFileDecorations: vscode.Event; + + private static _passedExercise = new vscode.FileDecoration( + "⬤", + "Exercise completed!", + new vscode.ThemeColor("gitDecoration.addedResourceForeground"), + ); + + private static _partiallyCompletedExercise = new vscode.FileDecoration( + "○", + "Some points gained", + new vscode.ThemeColor("gitDecoration.modifiedResourceForeground"), + ); + + private static _missingExercise = new vscode.FileDecoration( + "ⓘ", + "Exercise not found in course. This could be an old exercise that has been renamed or removed from course.", + new vscode.ThemeColor("gitDecoration.ignoredResourceForeground"), + ); + + private static _expiredExercise = new vscode.FileDecoration("✗", "Deadline exceeded."); + + private _eventEmiter: vscode.EventEmitter; + + /** + * Creates a new instance of an `ExerciseDecorationProvider`. + */ + constructor( + private readonly userData: UserData, + private readonly workspaceManager: WorkspaceManager, + ) { + this._eventEmiter = new vscode.EventEmitter(); + this.onDidChangeFileDecorations = this._eventEmiter.event; + } + + public dispose(): void { + this._eventEmiter.dispose(); + } + + public provideFileDecoration(uri: vscode.Uri): vscode.ProviderResult { + const exercise = this.workspaceManager.getExerciseByPath(uri); + if (!exercise || exercise.uri.fsPath !== uri.fsPath) { + return; + } + + const apiExercise = this.userData.getExerciseByName( + exercise.courseSlug, + exercise.exerciseSlug, + ); + if (!apiExercise) { + return ExerciseDecorationProvider._missingExercise; + } + + if (apiExercise.passed) { + return ExerciseDecorationProvider._passedExercise; + } + + const deadlinePassed = apiExercise.deadline + ? Date.now() > Date.parse(apiExercise.deadline) + : undefined; + if (deadlinePassed) { + return ExerciseDecorationProvider._expiredExercise; + } + + if (apiExercise.awardedPoints > 0) { + return ExerciseDecorationProvider._partiallyCompletedExercise; + } + } + + /** + * Trigger decorator event for given exercises. Requires this object to be registered with + * `vscode.window.registerFileDecorationProvider` for any effects to take any effect. + */ + public updateDecorationsForExercises(...exercises: ReadonlyArray): void { + this._eventEmiter.fire(exercises.map((x) => x.uri)); + } +} diff --git a/src/api/langsSchema.ts b/src/api/langsSchema.ts index cc2b9d34..7e422d47 100644 --- a/src/api/langsSchema.ts +++ b/src/api/langsSchema.ts @@ -1,17 +1,6 @@ -import { - Course, - CourseDetails, - CourseExercise, - CourseSettings, - ExerciseDetails, - OldSubmission, - Organization, - SubmissionFeedbackResponse, - SubmissionResponse, - SubmissionResultReport, -} from "./types"; - -// * Output schema for TMC-Langs 0.9.1 * +import { CourseDetails, CourseExercise, CourseSettings, SubmissionResponse } from "./types"; + +// * Output schema for TMC-Langs 0.17.3 * // ------------------------------------------------------------------------------------------------- // https://github.com/rage/tmc-langs-rust/blob/master/tmc-client/src/tmc_client.rs @@ -26,15 +15,22 @@ export type ClientUpdateData = // ------------------------------------------------------------------------------------------------- export type Output = - | ({ "output-kind": "output-data" } & OutputData) + | ({ "output-kind": "output-data" } & UncheckedOutputData) | ({ "output-kind": "status-update" } & StatusUpdateData) | ({ "output-kind": "warnings" } & Warnings); -export interface OutputData { +export interface UncheckedOutputData { + status: Status; + message: string; + result: OutputResult; + data: DataType | null; +} + +export interface OutputData { status: Status; message: string; result: OutputResult; - data: Data | null; + data: DataType; } interface DataType { @@ -42,37 +38,6 @@ interface DataType { "output-data": V; } -export type Data = - | DataType<"error", LangsError> - | DataType<"validation", unknown> - | DataType<"free-disk-space", number> - | DataType<"available-points", string[]> - | DataType<"exercises", string[]> - | DataType<"exercise-packaging-configuration", unknown> - | DataType<"local-exercises", LocalExercise[]> - | DataType<"refresh-result", unknown> - | DataType<"test-result", RunResult> - | DataType<"exercise-desc", unknown> - | DataType<"updated-exercises", UpdatedExercise[]> - | DataType<"exercise-download", DownloadOrUpdateCourseExercisesResult> - | DataType<"combined-course-data", CombinedCourseData> - | DataType<"course-details", CourseDetails["course"]> - | DataType<"course-exercises", CourseExercise[]> - | DataType<"course-data", CourseSettings> - | DataType<"courses", Course[]> - | DataType<"exercise-details", ExerciseDetails> - | DataType<"submissions", OldSubmission[]> - | DataType<"update-result", unknown> - | DataType<"organization", Organization> - | DataType<"organizations", Organization[]> - | DataType<"reviews", unknown> - | DataType<"token", unknown> - | DataType<"new-submission", SubmissionResponse> - | DataType<"submission-feedback-response", SubmissionFeedbackResponse> - | DataType<"submission-finished", SubmissionResultReport> - | DataType<"config-value", unknown> - | DataType<"tmc-config", TmcConfig>; - export type StatusUpdateData = | ({ "update-data-kind": "client-update-data" } & StatusUpdate) | ({ "update-data-kind": "none" } & StatusUpdate); @@ -86,18 +51,18 @@ export type OutputResult = | "error" | "executed-command"; -export interface LangsError { - kind: LangsErrorKind; +export interface ErrorResponse { + kind: ErrorResponseKind; trace: string[]; } export interface FailedExerciseDownload { - completed: DownloadOrUpdateCourseExercise[]; - skipped: DownloadOrUpdateCourseExercise[]; - failed: Array<[DownloadOrUpdateCourseExercise, string[]]>; + completed: ExerciseDownload[]; + skipped: ExerciseDownload[]; + failed: Array<[ExerciseDownload, string[]]>; } -export type LangsErrorKind = +export type ErrorResponseKind = | "generic" | "forbidden" | "not-logged-in" @@ -113,11 +78,13 @@ export interface CombinedCourseData { } export interface DownloadOrUpdateCourseExercisesResult { - downloaded: DownloadOrUpdateCourseExercise[]; - skipped: DownloadOrUpdateCourseExercise[]; + downloaded: ExerciseDownload[]; + skipped: ExerciseDownload[]; + failed?: Array<[ExerciseDownload, string[]]>; } -export interface DownloadOrUpdateCourseExercise { +export interface ExerciseDownload { + id: number; "course-slug": string; "exercise-slug": string; path: string; diff --git a/src/api/storage.ts b/src/api/storage.ts index a5c03e17..1ae3d653 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -26,6 +26,8 @@ export interface LocalCourseData { export interface LocalCourseExercise { id: number; + availablePoints: number; + awardedPoints: number; name: string; deadline: string | null; passed: boolean; @@ -62,6 +64,9 @@ export default class Storage { return this._context.globalState.get(Storage._userDataKey); } + /** + * @deprecated Extension Settings will be stored in VSCode, remove on major 3.0 release. + */ public getExtensionSettings(): ExtensionSettings | undefined { return this._context.globalState.get(Storage._extensionSettingsKey); } diff --git a/src/api/tmc.ts b/src/api/tmc.ts index db01ea8c..04e454cb 100644 --- a/src/api/tmc.ts +++ b/src/api/tmc.ts @@ -1,7 +1,7 @@ import * as cp from "child_process"; import * as kill from "tree-kill"; import { Err, Ok, Result } from "ts-results"; -import { is } from "typescript-is"; +import { createIs, is } from "typescript-is"; import { API_CACHE_LIFETIME, @@ -24,19 +24,17 @@ import { Logger } from "../utils/logger"; import { CombinedCourseData, - Data, DownloadOrUpdateCourseExercisesResult, - FailedExerciseDownload, + ErrorResponse, LocalExercise, Output, OutputData, RunResult, StatusUpdateData, - UpdatedExercise, + UncheckedOutputData, } from "./langsSchema"; import { Course, - CourseData, CourseDetails, CourseExercise, CourseSettings, @@ -45,7 +43,7 @@ import { Organization, SubmissionFeedback, SubmissionFeedbackResponse, - SubmissionResultReport, + SubmissionResponse, SubmissionStatusReport, TestResults, } from "./types"; @@ -62,7 +60,6 @@ interface ExecutionOptions { interface LangsProcessArgs { args: string[]; - core: boolean; env?: { [key: string]: string }; /** Which args should be obfuscated in logs. */ obfuscate?: number[]; @@ -74,11 +71,11 @@ interface LangsProcessArgs { interface LangsProcessRunner { interrupt(): void; - result: Promise>; + result: Promise>; } interface ResponseCacheEntry { - response: OutputData; + response: UncheckedOutputData; timestamp: number; } @@ -86,11 +83,11 @@ interface CacheOptions { forceRefresh?: boolean; } -interface CacheConfig { +interface CacheConfig { forceRefresh?: boolean; key: string; /** Optional remapper for assigning parts of the result to different keys. */ - remapper?: (response: OutputData) => Array<[string, OutputData]>; + remapper?: (response: T) => Array<[string, OutputData]>; } /** @@ -152,18 +149,23 @@ export default class TMC { * @param password Password. */ public async authenticate(username: string, password: string): Promise> { - const loginResult = await this._executeLangsCommand({ - args: ["login", "--email", username, "--base64"], - core: true, - obfuscate: [2], - stdin: Buffer.from(password).toString("base64"), - }); - if (loginResult.err) { - return Err(new AuthenticationError(loginResult.val.message)); + if (!username || !password) { + return Err(new AuthenticationError("Username and password may not be empty.")); } - - this._onLogin?.(); - return Ok.EMPTY; + const res = await this._executeLangsCommand( + { + args: ["core", "login", "--email", username, "--base64"], + obfuscate: [3], + stdin: Buffer.from(password).toString("base64"), + }, + createIs(), + ); + return res + .mapErr((x) => new AuthenticationError(x.message)) + .andThen(() => { + this._onLogin?.(); + return Ok.EMPTY; + }); } /** @@ -173,37 +175,38 @@ export default class TMC { * @returns Boolean indicating if the user is authenticated. */ public async isAuthenticated(options?: ExecutionOptions): Promise> { - const loggedInResult = await this._executeLangsCommand({ - args: ["logged-in"], - core: true, - processTimeout: options?.timeout, + const res = await this._executeLangsCommand( + { + args: ["core", "logged-in"], + processTimeout: options?.timeout, + }, + createIs(), + ); + return res.andThen((x) => { + switch (x.result) { + case "logged-in": + return Ok(true); + case "not-logged-in": + return Ok(false); + default: + return Err(new Error(`Unexpected langs result: ${x.result}`)); + } }); - if (loggedInResult.err) { - return loggedInResult; - } - - switch (loggedInResult.val.result) { - case "logged-in": - return new Ok(true); - case "not-logged-in": - return new Ok(false); - default: - return Err(new Error(`Unexpected langs result: ${loggedInResult.val.result}`)); - } } /** * Deauthenticates current user. Uses TMC-langs `logout` core command internally. */ public async deauthenticate(): Promise> { - const logoutResult = await this._executeLangsCommand({ args: ["logout"], core: true }); - if (logoutResult.err) { - return logoutResult; - } - - this._responseCache.clear(); - this._onLogout?.(); - return Ok.EMPTY; + const res = await this._executeLangsCommand( + { args: ["core", "logout"] }, + createIs(), + ); + return res.andThen(() => { + this._responseCache.clear(); + this._onLogout?.(); + return Ok.EMPTY; + }); } // --------------------------------------------------------------------------------------------- @@ -217,10 +220,11 @@ export default class TMC { * @param id ID of the exercise to clean. */ public async clean(exercisePath: string): Promise> { - return this._executeLangsCommand({ - args: ["clean", "--exercise-path", exercisePath], - core: false, - }).then((res) => (res.err ? res : Ok.EMPTY)); + const res = await this._executeLangsCommand( + { args: ["clean", "--exercise-path", exercisePath] }, + createIs(), + ); + return res.err ? res : Ok.EMPTY; } /** @@ -232,47 +236,11 @@ export default class TMC { public async listLocalCourseExercises( courseSlug: string, ): Promise> { - return ( - await this._executeLangsCommand({ - args: [ - "list-local-course-exercises", - "--client-name", - this.clientName, - "--course-slug", - courseSlug, - ], - core: false, - }) - ).andThen((x) => - x.data?.["output-data-kind"] === "local-exercises" - ? Ok(x.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), + const res = await this._executeLangsCommand( + { args: ["list-local-course-exercises", "--course-slug", courseSlug] }, + createIs>(), ); - } - - /** - * Moves this instance's projects directory on disk. Uses TMC-langs `settings move-projects-dir` - * setting internally. - * - * @param newDirectory New location for projects directory. - * @param onUpdate Progress callback. - */ - public async moveProjectsDirectory( - newDirectory: string, - onUpdate?: (value: { percent: number; message?: string }) => void, - ): Promise> { - const onStdout = (res: StatusUpdateData): void => { - onUpdate?.({ - percent: res["percent-done"], - message: res.message ?? undefined, - }); - }; - - return this._executeLangsCommand({ - args: ["settings", "--client-name", this.clientName, "move-projects-dir", newDirectory], - core: false, - onStdout, - }).then((res) => (res.err ? res : Ok.EMPTY)); + return res.map((x) => x.data["output-data"]); } /** @@ -292,21 +260,15 @@ export default class TMC { } const { interrupt, result } = this._spawnLangsProcess({ args: ["run-tests", "--exercise-path", exercisePath], - core: false, env, onStderr: (data) => Logger.log("Rust Langs", data), processTimeout: CLI_PROCESS_TIMEOUT, }); const postResult = result.then((res) => res - .andThen(this._checkLangsResponse) - .andThen((x) => - x.data?.["output-data-kind"] === "test-result" - ? Ok(x.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), - ), + .andThen((x) => this._checkLangsResponse(x, createIs>())) + .map((x) => x.data["output-data"]), ); - return [postResult, interrupt]; } @@ -325,25 +287,53 @@ export default class TMC { exercisePath: string, exerciseSlug: string, ): Promise> { - return this._executeLangsCommand({ - args: [ - "settings", - "--client-name", - this.clientName, - "migrate", - "--course-slug", - courseSlug, - "--exercise-checksum", - exerciseChecksum, - "--exercise-id", - `${exerciseId}`, - "--exercise-path", - exercisePath, - "--exercise-slug", - exerciseSlug, - ], - core: false, - }).then((res) => (res.err ? res : Ok.EMPTY)); + const res = await this._executeLangsCommand( + { + args: [ + "settings", + "migrate", + "--course-slug", + courseSlug, + "--exercise-checksum", + exerciseChecksum, + "--exercise-id", + `${exerciseId}`, + "--exercise-path", + exercisePath, + "--exercise-slug", + exerciseSlug, + ], + }, + createIs(), + ); + return res.err ? res : Ok.EMPTY; + } + + /** + * Moves this instance's projects directory on disk. Uses TMC-langs `settings move-projects-dir` + * setting internally. + * + * @param newDirectory New location for projects directory. + * @param onUpdate Progress callback. + */ + public async moveProjectsDirectory( + newDirectory: string, + onUpdate?: (value: { percent: number; message?: string }) => void, + ): Promise> { + const onStdout = (res: StatusUpdateData): void => { + onUpdate?.({ + percent: res["percent-done"], + message: res.message ?? undefined, + }); + }; + const res = await this._executeLangsCommand( + { + args: ["settings", "move-projects-dir", newDirectory], + onStdout, + }, + createIs(), + ); + return res.err ? res : Ok.EMPTY; } /** @@ -354,31 +344,29 @@ export default class TMC { key: string, checker: (object: unknown) => object is T, ): Promise> { - return ( - await this._executeLangsCommand({ - args: ["settings", "--client-name", this.clientName, "get", key], - core: false, - }) - ) - .andThen((result) => - result.data?.["output-data-kind"] === "config-value" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), - ) - .andThen((result) => - checker(result) ? Ok(result) : Err(new Error("Invalid object type.")), - ); + const res = await this._executeLangsCommand( + { args: ["settings", "get", key] }, + createIs(), + ); + return res.andThen((x) => { + const data = x.data?.["output-data"]; + if (data === undefined || data === null) { + return Ok(undefined); + } + return checker(data) ? Ok(data) : Err(new Error("Invalid object type.")); + }); } /** * Sets a value for given key in stored settings. Uses TMC-langs `settings set` command * internally. */ - public async setSetting(key: string, value: string): Promise> { - return this._executeLangsCommand({ - args: ["settings", "--client-name", this.clientName, "set", key, value], - core: false, - }).then((x) => x.andThen(() => Ok.EMPTY)); + public async setSetting(key: string, value: unknown): Promise> { + const res = await this._executeLangsCommand( + { args: ["settings", "set", key, JSON.stringify(value)] }, + createIs(), + ); + return res.err ? res : Ok.EMPTY; } /** @@ -386,10 +374,11 @@ export default class TMC { * internally. */ public async resetSettings(): Promise> { - return this._executeLangsCommand({ - args: ["settings", "--client-name", this.clientName, "reset"], - core: false, - }).then((x) => x.andThen(() => Ok.EMPTY)); + const res = await this._executeLangsCommand( + { args: ["settings", "reset"] }, + createIs(), + ); + return res.err ? res : Ok.EMPTY; } /** @@ -397,10 +386,11 @@ export default class TMC { * internally. */ public async unsetSetting(key: string): Promise> { - return this._executeLangsCommand({ - args: ["settings", "--client-name", this.clientName, "unset", key], - core: false, - }).then((x) => x.andThen(() => Ok.EMPTY)); + const res = await this._executeLangsCommand( + { args: ["settings", "unset", key] }, + createIs(), + ); + return res.err ? res : Ok.EMPTY; } // --------------------------------------------------------------------------------------------- @@ -414,36 +404,12 @@ export default class TMC { public async checkExerciseUpdates( options?: CacheOptions, ): Promise, Error>> { - return ( - await this._executeLangsCommand( - { args: ["check-exercise-updates"], core: true }, - { forceRefresh: options?.forceRefresh, key: TMC._exerciseUpdatesCacheKey }, - ) - ).andThen((result) => - result.data?.["output-data-kind"] === "updated-exercises" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), + const res = await this._executeLangsCommand( + { args: ["core", "check-exercise-updates"] }, + createIs>>(), + { forceRefresh: options?.forceRefresh, key: TMC._exerciseUpdatesCacheKey }, ); - } - - /** - * @deprecated - Migrate to `downloadExercises` - * Downloads an exercise to the provided filepath. Uses TMC-langs `download-or-update-exercise` - * core command internally. - * - * @param id Id of the exercise to download. - * @param exercisePath Filepath where the exercise should be downloaded to. - */ - public async downloadExercise( - id: number, - exercisePath: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - progressCallback?: (downloadedPct: number, increment: number) => void, - ): Promise> { - return this._executeLangsCommand({ - args: ["download-or-update-exercises", "--exercise", id.toString(), exercisePath], - core: true, - }).then((res) => (res.err ? res : Ok.EMPTY)); + return res.map((x) => x.data["output-data"]); } /** @@ -451,46 +417,44 @@ export default class TMC { * `download-or-update-course-exercises` core command internally. * * @param ids Ids of the exercises to download. + * @param downloadTemplate Flag for downloading exercise template instead of latest submission. */ public async downloadExercises( ids: number[], - downloaded: (value: { id: number; percent: number; message?: string }) => void, + downloadTemplate: boolean, + onDownloaded: (value: { id: number; percent: number; message?: string }) => void, ): Promise> { const onStdout = (res: StatusUpdateData): void => { if ( res["update-data-kind"] === "client-update-data" && res.data?.["client-update-data-kind"] === "exercise-download" ) { - downloaded({ + onDownloaded({ id: res.data.id, percent: res["percent-done"], message: res.message ?? undefined, }); } }; - - const result = ( - await this._executeLangsCommand({ + const flags = downloadTemplate ? ["--download-template"] : []; + const res = await this._executeLangsCommand( + { args: [ + "core", "download-or-update-course-exercises", + ...flags, "--exercise-id", ...ids.map((id) => id.toString()), ], - core: true, onStdout, - }) - ).andThen((result) => - result.data?.["output-data-kind"] === "exercise-download" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), + }, + createIs>(), ); - if (result.err) { - return result; - } - - // Invalidate exercise update cache - this._responseCache.delete(TMC._exerciseUpdatesCacheKey); - return result; + return res.andThen((x) => { + // Invalidate exercise update cache + this._responseCache.delete(TMC._exerciseUpdatesCacheKey); + return Ok(x.data["output-data"]); + }); } /** @@ -512,6 +476,7 @@ export default class TMC { ): Promise> { const flags = saveOldState ? ["--save-old-state"] : []; const args = [ + "core", "download-old-submission", ...flags, "--exercise-id", @@ -521,17 +486,8 @@ export default class TMC { "--submission-id", submissionId.toString(), ]; - - if (saveOldState) { - args.push( - "--submission-url", - `${TMC_BACKEND_URL}/api/v8/core/exercises/${exerciseId}/submissions`, - ); - } - - return this._executeLangsCommand({ args, core: true }).then((res) => - res.err ? res : Ok.EMPTY, - ); + const res = await this._executeLangsCommand({ args }, createIs()); + return res.err ? res : Ok.EMPTY; } /** @@ -545,19 +501,15 @@ export default class TMC { organization: string, options?: CacheOptions, ): Promise> { - return ( - await this._executeLangsCommand( - { args: ["get-courses", "--organization", organization], core: true }, - { - forceRefresh: options?.forceRefresh, - key: `organization-${organization}-courses`, - }, - ) - ).andThen((result) => - result.data?.["output-data-kind"] === "courses" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), + const res = await this._executeLangsCommand( + { args: ["core", "get-courses", "--organization", organization] }, + createIs>(), + { + forceRefresh: options?.forceRefresh, + key: `organization-${organization}-courses`, + }, ); + return res.map((x) => x.data["output-data"]); } /** @@ -570,8 +522,8 @@ export default class TMC { public async getCourseData( courseId: number, options?: CacheOptions, - ): Promise> { - const remapper: CacheConfig["remapper"] = (response) => { + ): Promise> { + const remapper: CacheConfig>["remapper"] = (response) => { if (response.data?.["output-data-kind"] !== "combined-course-data") return []; const { details, exercises, settings } = response.data["output-data"]; return [ @@ -598,17 +550,12 @@ export default class TMC { ], ]; }; - - return ( - await this._executeLangsCommand( - { args: ["get-course-data", "--course-id", courseId.toString()], core: true }, - { forceRefresh: options?.forceRefresh, key: `course-${courseId}-data`, remapper }, - ) - ).andThen((result) => - result.data?.["output-data-kind"] === "combined-course-data" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), + const res = await this._executeLangsCommand>( + { args: ["core", "get-course-data", "--course-id", courseId.toString()] }, + createIs>(), + { forceRefresh: options?.forceRefresh, key: `course-${courseId}-data`, remapper }, ); + return res.map((x) => x.data["output-data"]); } /** @@ -622,18 +569,12 @@ export default class TMC { courseId: number, options?: CacheOptions, ): Promise> { - return ( - await this._executeLangsCommand( - { args: ["get-course-details", "--course-id", courseId.toString()], core: true }, - { forceRefresh: options?.forceRefresh, key: `course-${courseId}-details` }, - ) - ) - .andThen((result) => - result.data?.["output-data-kind"] === "course-details" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), - ) - .map((x) => ({ course: x })); + const res = await this._executeLangsCommand( + { args: ["core", "get-course-details", "--course-id", courseId.toString()] }, + createIs>(), + { forceRefresh: options?.forceRefresh, key: `course-${courseId}-details` }, + ); + return res.map((x) => ({ course: x.data["output-data"] })); } /** @@ -647,16 +588,12 @@ export default class TMC { courseId: number, options?: CacheOptions, ): Promise> { - return ( - await this._executeLangsCommand( - { args: ["get-course-exercises", "--course-id", courseId.toString()], core: true }, - { forceRefresh: options?.forceRefresh, key: `course-${courseId}-exercises` }, - ) - ).andThen((result) => - result.data?.["output-data-kind"] === "course-exercises" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), + const res = await this._executeLangsCommand( + { args: ["core", "get-course-exercises", "--course-id", courseId.toString()] }, + createIs>(), + { forceRefresh: options?.forceRefresh, key: `course-${courseId}-exercises` }, ); + return res.map((x) => x.data["output-data"]); } /** @@ -670,16 +607,12 @@ export default class TMC { courseId: number, options?: CacheOptions, ): Promise> { - return ( - await this._executeLangsCommand( - { args: ["get-course-settings", "--course-id", courseId.toString()], core: true }, - { forceRefresh: options?.forceRefresh, key: `course-${courseId}-settings` }, - ) - ).andThen((result) => - result.data?.["output-data-kind"] === "course-data" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), + const res = await this._executeLangsCommand( + { args: ["core", "get-course-settings", "--course-id", courseId.toString()] }, + createIs>(), + { forceRefresh: options?.forceRefresh, key: `course-${courseId}-settings` }, ); + return res.map((x) => x.data["output-data"]); } /** @@ -693,19 +626,12 @@ export default class TMC { exerciseId: number, options?: CacheOptions, ): Promise> { - return ( - await this._executeLangsCommand( - { - args: ["get-exercise-details", "--exercise-id", exerciseId.toString()], - core: true, - }, - { forceRefresh: options?.forceRefresh, key: `exercise-${exerciseId}-details` }, - ) - ).andThen((result) => - result.data?.["output-data-kind"] === "exercise-details" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), + const res = await this._executeLangsCommand( + { args: ["core", "get-exercise-details", "--exercise-id", exerciseId.toString()] }, + createIs>(), + { forceRefresh: options?.forceRefresh, key: `exercise-${exerciseId}-details` }, ); + return res.map((x) => x.data["output-data"]); } /** @@ -716,16 +642,11 @@ export default class TMC { * @returns Array of old submissions. */ public async getOldSubmissions(exerciseId: number): Promise> { - return ( - await this._executeLangsCommand({ - args: ["get-exercise-submissions", "--exercise-id", exerciseId.toString()], - core: true, - }) - ).andThen((result) => - result.data?.["output-data-kind"] === "submissions" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), + const res = await this._executeLangsCommand( + { args: ["core", "get-exercise-submissions", "--exercise-id", exerciseId.toString()] }, + createIs>(), ); + return res.map((x) => x.data["output-data"]); } /** @@ -739,16 +660,12 @@ export default class TMC { organizationSlug: string, options?: CacheOptions, ): Promise> { - return ( - await this._executeLangsCommand( - { args: ["get-organization", "--organization", organizationSlug], core: true }, - { forceRefresh: options?.forceRefresh, key: `organization-${organizationSlug}` }, - ) - ).andThen((result) => - result.data?.["output-data-kind"] === "organization" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), + const res = await this._executeLangsCommand( + { args: ["core", "get-organization", "--organization", organizationSlug] }, + createIs>(), + { forceRefresh: options?.forceRefresh, key: `organization-${organizationSlug}` }, ); + return res.map((x) => x.data["output-data"]); } /** @@ -757,24 +674,19 @@ export default class TMC { * @returns A list of organizations. */ public async getOrganizations(options?: CacheOptions): Promise> { - const remapper: CacheConfig["remapper"] = (res) => { + const remapper: CacheConfig>["remapper"] = (res) => { if (res.data?.["output-data-kind"] !== "organizations") return []; - return res.data["output-data"].map<[string, OutputData]>((x) => [ + return res.data["output-data"].map((x) => [ `organization-${x.slug}`, { ...res, data: { "output-data-kind": "organization", "output-data": x } }, ]); }; - - return ( - await this._executeLangsCommand( - { args: ["get-organizations"], core: true }, - { forceRefresh: options?.forceRefresh, key: "organizations", remapper }, - ) - ).andThen((result) => - result.data?.["output-data-kind"] === "organizations" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), + const res = await this._executeLangsCommand( + { args: ["core", "get-organizations"] }, + createIs>(), + { forceRefresh: options?.forceRefresh, key: "organizations", remapper }, ); + return res.map((x) => x.data["output-data"]); } /** @@ -791,6 +703,7 @@ export default class TMC { ): Promise> { const flags = saveOldState ? ["--save-old-state"] : []; const args = [ + "core", "reset-exercise", ...flags, "--exercise-id", @@ -798,19 +711,8 @@ export default class TMC { "--exercise-path", exercisePath, ]; - if (saveOldState) { - args.push( - "--submission-url", - `${TMC_BACKEND_URL}/api/v8/core/exercises/${exerciseId}/submissions`, - ); - } - - const result = await this._executeLangsCommand({ args, core: true }); - if (result.err) { - return result; - } - - return Ok.EMPTY; + const res = await this._executeLangsCommand({ args }, createIs()); + return res.err ? res : Ok.EMPTY; } /** @@ -836,7 +738,6 @@ export default class TMC { this._nextSubmissionAllowedTimestamp = now + MINIMUM_SUBMISSION_INTERVAL; } - const submitUrl = `${TMC_BACKEND_URL}/api/v8/core/exercises/${exerciseId}/submissions`; const onStdout = (res: StatusUpdateData): void => { progressCallback?.(100 * res["percent-done"], res.message ?? undefined); if ( @@ -847,17 +748,21 @@ export default class TMC { } }; - return ( - await this._executeLangsCommand({ - args: ["submit", "--submission-path", exercisePath, "--submission-url", submitUrl], - core: true, + const res = await this._executeLangsCommand( + { + args: [ + "core", + "submit", + "--exercise-id", + exerciseId.toString(), + "--submission-path", + exercisePath, + ], onStdout, - }) - ).andThen((result) => - result.data?.["output-data-kind"] === "submission-finished" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), + }, + createIs>(), ); + return res.map((x) => x.data["output-data"]); } /** @@ -880,19 +785,20 @@ export default class TMC { } else { this._nextSubmissionAllowedTimestamp = now + MINIMUM_SUBMISSION_INTERVAL; } - - const submitUrl = `${TMC_BACKEND_URL}/api/v8/core/exercises/${exerciseId}/submissions`; - - return ( - await this._executeLangsCommand({ - args: ["paste", "--submission-path", exercisePath, "--submission-url", submitUrl], - core: true, - }) - ).andThen((result) => - result.data?.["output-data-kind"] === "new-submission" - ? Ok(result.data["output-data"].paste_url) - : Err(new Error("Unexpected Langs result.")), + const res = await this._executeLangsCommand( + { + args: [ + "core", + "paste", + "--exercise-id", + exerciseId.toString(), + "--submission-path", + exercisePath, + ], + }, + createIs>(), ); + return res.map((x) => x.data["output-data"].paste_url); } /** @@ -910,17 +816,11 @@ export default class TMC { (acc, next) => acc.concat("--feedback", next.question_id.toString(), next.answer), [], ); - - return ( - await this._executeLangsCommand({ - args: ["send-feedback", ...feedbackArgs, "--feedback-url", feedbackUrl], - core: true, - }) - ).andThen((result) => - result.data?.["output-data-kind"] === "submission-feedback-response" - ? Ok(result.data["output-data"]) - : Err(new Error("Unexpected Langs result.")), + const res = await this._executeLangsCommand( + { args: ["core", "send-feedback", ...feedbackArgs, "--feedback-url", feedbackUrl] }, + createIs>(), ); + return res.map((x) => x.data["output-data"]); } /** @@ -928,17 +828,14 @@ export default class TMC { * validation for the last response received from the process. * * @param langsArgs Command arguments passed on to spawnLangsProcess. - * @param checker Checker function used to validate the type of data-property. - * @param useCache Whether to try fetching the data from cache instead of running the process. - * @param cacheKey Key used for storing and accessing cached data. Required with useCache. - * @param cacheTransformer Optional transformer function that can be used to split summary - * responses. - * @returns Result that resolves to a checked LansResponse. + * @param validator Validator used to check that the result corresponds to the expected type. + * @param cacheConfig Cache options. */ - private async _executeLangsCommand( + private async _executeLangsCommand( langsArgs: LangsProcessArgs, - cacheConfig?: CacheConfig, - ): Promise> { + validator: (object: unknown) => object is T, + cacheConfig?: CacheConfig, + ): Promise> { const cacheKey = cacheConfig?.key; const currentTime = Date.now(); if (!cacheConfig?.forceRefresh && cacheKey) { @@ -946,7 +843,7 @@ export default class TMC { if (cachedEntry) { const { response, timestamp } = cachedEntry; const cachedDataLifeLeft = timestamp + API_CACHE_LIFETIME - currentTime; - if (cachedDataLifeLeft > 0) { + if (validator(response) && cachedDataLifeLeft > 0) { const prettySecondsLeft = Math.ceil(cachedDataLifeLeft / 1000); Logger.log( `Using cached data for key: ${cacheKey}. Still valid for ${prettySecondsLeft}s`, @@ -958,40 +855,45 @@ export default class TMC { } } - const result = (await this._spawnLangsProcess(langsArgs).result).andThen((x) => - this._checkLangsResponse(x), - ); - if (result.err) { - return result; - } - - const response = result.val; - if (response && cacheKey) { - this._responseCache.set(cacheKey, { response, timestamp: currentTime }); - cacheConfig?.remapper?.(response).forEach(([key, response]) => { - this._responseCache.set(key, { response, timestamp: currentTime }); + const res = await this._spawnLangsProcess(langsArgs).result; + return res + .andThen((x) => this._checkLangsResponse(x, validator)) + .andThen((x) => { + if (x && cacheKey) { + this._responseCache.set(cacheKey, { response: x, timestamp: currentTime }); + cacheConfig?.remapper?.(x).forEach(([key, response]) => { + this._responseCache.set(key, { response, timestamp: currentTime }); + }); + } + return Ok(x); }); - } - - return Ok(response); } /** * Checks langs response for generic errors. */ - private _checkLangsResponse(langsResponse: OutputData): Result { + private _checkLangsResponse( + langsResponse: UncheckedOutputData, + validator: (object: unknown) => object is T, + ): Result { if (langsResponse.status === "crashed") { Logger.error(`Langs process crashed: ${langsResponse.message}`, langsResponse.data); return Err(new RuntimeError("Langs process crashed.")); } - if (langsResponse.data?.["output-data-kind"] !== "error") { + if (langsResponse.data?.["output-data-kind"] !== "error" && validator(langsResponse)) { return Ok(langsResponse); } + const data = langsResponse.data?.["output-data"]; + if (!is(data)) { + Logger.debug(`Unexpected TMC-langs response. ${langsResponse.data}`); + return Err(new Error("Unexpected TMC-langs response.")); + } + const message = langsResponse.message; - const traceString = langsResponse.data["output-data"].trace.join("\n"); - const outputDataKind = langsResponse.data["output-data"].kind; + const traceString = data.trace.join("\n"); + const outputDataKind = data.kind; switch (outputDataKind) { case "connection-error": return Err(new ConnectionError(message, traceString)); @@ -1016,19 +918,6 @@ export default class TMC { ); } - // Special handling because it makes usage simpler - if (is(outputDataKind)) { - const data: Data = { - "output-data-kind": "exercise-download", - "output-data": { - downloaded: outputDataKind.completed, - skipped: outputDataKind.skipped, - }, - }; - - return Ok({ ...langsResponse, data }); - } - return Err(new RuntimeError(message, traceString)); } @@ -1038,25 +927,24 @@ export default class TMC { * @returns Rust process runner. */ private _spawnLangsProcess(commandArgs: LangsProcessArgs): LangsProcessRunner { - const { args, core, env, obfuscate, onStderr, onStdout, stdin, processTimeout } = - commandArgs; - const CORE_ARGS = [ - "core", + const { args, env, obfuscate, onStderr, onStdout, stdin, processTimeout } = commandArgs; + const clientInfo = [ "--client-name", this.clientName, "--client-version", this.clientVersion, ]; - let theResult: OutputData | undefined; + let theResult: UncheckedOutputData | undefined; let stdoutBuffer = ""; - const executableArgs = core ? CORE_ARGS.concat(args) : args; + const executableArgs = clientInfo.concat(args); const obfuscatedArgs = args.map((x, i) => (obfuscate?.includes(i) ? "***" : x)); - const logableArgs = core ? CORE_ARGS.concat(obfuscatedArgs) : obfuscatedArgs; - Logger.log( - "Run: " + [this.cliPath, ...logableArgs].map((x) => JSON.stringify(x)).join(" "), - ); + const logableCommand = [this.cliPath] + .concat(clientInfo, obfuscatedArgs) + .map((x) => JSON.stringify(x)) + .join(" "); + Logger.log(`Run: ${logableCommand}`); let active = true; let interrupted = false; @@ -1108,7 +996,11 @@ export default class TMC { stdoutBuffer = parts.pop() || ""; for (const part of parts) { try { - const json = JSON.parse(part.trim()); + const trimmed = part.trim(); + if (!trimmed) { + continue; + } + const json = JSON.parse(trimmed); if (!is(json)) { Logger.error("TMC-langs response didn't match expected type"); Logger.debug(part); diff --git a/src/api/types.ts b/src/api/types.ts index 0a17ed13..ca2acb8d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -114,12 +114,6 @@ export type CourseSettings = { organization_slug?: string | null; }; -export type CourseData = { - details: CourseDetails["course"]; - exercises: CourseExercise[]; - settings: CourseSettings; -}; - /** * GET /api/v8/core/exercises/{exercise_id} */ diff --git a/src/api/workspaceManager.ts b/src/api/workspaceManager.ts index 449fe1b0..50d10443 100644 --- a/src/api/workspaceManager.ts +++ b/src/api/workspaceManager.ts @@ -5,6 +5,9 @@ import { Err, Ok, Result } from "ts-results"; import * as vscode from "vscode"; import { + HIDE_META_FILES, + SHOW_META_FILES, + WATCHER_EXCLUDE, WORKSPACE_ROOT_FILE_NAME, WORKSPACE_ROOT_FILE_TEXT, WORKSPACE_ROOT_FOLDER_NAME, @@ -26,6 +29,15 @@ export interface WorkspaceExercise { uri: vscode.Uri; } +interface ConfigurationProperties { + default?: unknown; + type?: string; + description?: string; + scope?: string; + enum?: Array; + enumDescriptions?: Array; +} + /** * Class for managing active workspace. */ @@ -81,6 +93,20 @@ export default class WorkspaceManager implements vscode.Disposable { return uri && this.getExerciseByPath(uri); } + /** + * Currently active course workspace uri, or `undefined` otherwise. + */ + public get workspaceFileUri(): vscode.Uri | undefined { + const workspaceFile = vscode.workspace.workspaceFile; + if ( + !workspaceFile || + path.relative(workspaceFile.fsPath, this._resources.workspaceFileFolder) !== ".." + ) { + return undefined; + } + return workspaceFile; + } + public async setExercises(exercises: WorkspaceExercise[]): Promise> { this._exercises = exercises; return this._refreshActiveCourseWorkspace(); @@ -184,6 +210,85 @@ export default class WorkspaceManager implements vscode.Disposable { this._disposables.forEach((x) => x.dispose()); } + public async excludeMetaFilesInWorkspace(hide: boolean): Promise { + const value = hide ? HIDE_META_FILES : SHOW_META_FILES; + await this.updateWorkspaceSetting("files.exclude", value); + } + + /** + * Returns the section for the Workspace setting (i.e. .code-workspace). + * If section not found in multi-root workspace file, returns User scope setting. + * @param section A dot-separated identifier. + */ + public getWorkspaceSettings(section?: string): vscode.WorkspaceConfiguration { + if (this.activeCourse) { + return vscode.workspace.getConfiguration(section, this.workspaceFileUri); + } + return vscode.workspace.getConfiguration(section); + } + + public async verifyWorkspaceSettingsIntegrity(): Promise { + if (this.activeCourse) { + Logger.log("TMC Workspace open, verifying workspace settings integrity."); + const hideMetaFiles = this.getWorkspaceSettings("testMyCode").get( + "hideMetaFiles", + true, + ); + await this.excludeMetaFilesInWorkspace(hideMetaFiles); + await this._ensureSettingsAreStoredInMultiRootWorkspace(); + await this._verifyWatcherPatternExclusion(); + await this._forceTMCWorkspaceSettings(); + } + } + + /** + * Updates a section for the TMC Workspace.code-workspace file, if the workspace is open. + * @param section Configuration name, supports dotted names. + * @param value The new value + */ + public async updateWorkspaceSetting(section: string, value: unknown): Promise { + const activeCourse = this.activeCourse; + if (activeCourse) { + let newValue = value; + if (value instanceof Object) { + const oldValue = this.getWorkspaceSettings(section); + newValue = { ...oldValue, ...value }; + } + await vscode.workspace + .getConfiguration( + undefined, + vscode.Uri.file(this._resources.getWorkspaceFilePath(activeCourse)), + ) + .update(section, newValue, vscode.ConfigurationTarget.Workspace); + } + } + + /** + * Ensures that settings defined in package.json are written to the multi-root + * workspace file. If the key can't be found in the .code-workspace file, it will write the + * setting defined in the User scope to the file. Last resort, default value. + * + * This is to ensure that workspace defined settings really overrides the user scope... + * https://github.com/microsoft/vscode/issues/58038 + */ + private async _ensureSettingsAreStoredInMultiRootWorkspace(): Promise { + const extension = vscode.extensions.getExtension("moocfi.test-my-code"); + const extensionDefinedSettings: Record = + extension?.packageJSON?.contributes?.configuration?.properties; + for (const [key, value] of Object.entries(extensionDefinedSettings)) { + if (value.scope !== "application" && value.type === "boolean") { + const codeSettings = this.getWorkspaceSettings().inspect(key); + if (codeSettings?.workspaceValue !== undefined) { + await this.updateWorkspaceSetting(key, codeSettings.workspaceValue); + } else if (codeSettings?.globalValue !== undefined) { + await this.updateWorkspaceSetting(key, codeSettings.globalValue); + } else { + await this.updateWorkspaceSetting(key, codeSettings?.defaultValue); + } + } + } + } + /** * Event listener function for workspace watcher delete. * @param targetPath Path to deleted item @@ -203,6 +308,15 @@ export default class WorkspaceManager implements vscode.Disposable { } } + /** + * Force some settings for TMC multi-root workspaces that we want. + */ + private async _forceTMCWorkspaceSettings(): Promise { + await this.updateWorkspaceSetting("explorer.decorations.colors", false); + await this.updateWorkspaceSetting("explorer.decorations.badges", true); + await this.updateWorkspaceSetting("problems.decorations.enabled", false); + } + /** * Refreshes current active course workspace by first making sure that the `.tmc` folder is at * the top and then lists all that course's open exercises in alphanumeric order. @@ -331,4 +445,13 @@ export default class WorkspaceManager implements vscode.Disposable { break; } } + + /** + * Makes sure that folders and its contents aren't deleted by our watcher. + * .vscode folder needs to be unwatched, otherwise adding settings to WorkspaceFolder level + * doesn't work. For example defining Python interpreter for the Exercise folder. + */ + private async _verifyWatcherPatternExclusion(): Promise { + await this.updateWorkspaceSetting("files.watcherExclude", { ...WATCHER_EXCLUDE }); + } } diff --git a/src/commands/addNewCourse.ts b/src/commands/addNewCourse.ts index 4f440f4a..2d7d398e 100644 --- a/src/commands/addNewCourse.ts +++ b/src/commands/addNewCourse.ts @@ -30,10 +30,7 @@ export async function addNewCourse(actionContext: ActionContext): Promise return; } - const result = await actions.addNewCourse(actionContext, { - organization: chosenOrg, - course: chosenCourse, - }); + const result = await actions.addNewCourse(actionContext, chosenOrg, chosenCourse); if (result.err) { dialog.errorNotification("Failed to add course.", result.val); } diff --git a/src/commands/changeTmcDataPath.ts b/src/commands/changeTmcDataPath.ts new file mode 100644 index 00000000..6d89fd5d --- /dev/null +++ b/src/commands/changeTmcDataPath.ts @@ -0,0 +1,46 @@ +import du = require("du"); +import * as vscode from "vscode"; + +import { moveExtensionDataPath } from "../actions"; +import { ActionContext } from "../actions/types"; +import { formatSizeInBytes, Logger } from "../utils"; + +/** + * Removes language specific meta files from exercise directory. + */ +export async function changeTmcDataPath(actionContext: ActionContext): Promise { + const { dialog, resources, ui } = actionContext; + + const old = resources.projectsDirectory; + const options: vscode.OpenDialogOptions = { + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: "Select folder", + }; + const newPath = (await vscode.window.showOpenDialog(options))?.[0]; + if (newPath && old) { + const res = await dialog.progressNotification( + "Moving projects directory...", + (progress) => { + return moveExtensionDataPath(actionContext, newPath, (update) => + progress.report(update), + ); + }, + ); + if (res.ok) { + Logger.log(`Moved workspace folder from ${old} to ${newPath.fsPath}`); + dialog.notification(`TMC Data was successfully moved to ${newPath.fsPath}`, [ + "OK", + (): void => {}, + ]); + } else { + dialog.errorNotification(res.val.message, res.val); + } + ui.webview.postMessage({ + command: "setTmcDataFolder", + diskSize: formatSizeInBytes(await du(resources.projectsDirectory)), + path: resources.projectsDirectory, + }); + } +} diff --git a/src/commands/index.ts b/src/commands/index.ts index 391a1a6e..4f06bc3a 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,4 +1,5 @@ export * from "./addNewCourse"; +export * from "./changeTmcDataPath"; export * from "./cleanExercise"; export * from "./closeExercise"; export * from "./downloadNewExercises"; diff --git a/src/commands/showWelcome.ts b/src/commands/showWelcome.ts index f1230478..562c8404 100644 --- a/src/commands/showWelcome.ts +++ b/src/commands/showWelcome.ts @@ -15,11 +15,8 @@ export async function showWelcome(actionContext: ActionContext): Promise { { templateName: "welcome", version: resources.extensionVersion, - newTreeView: vscode.Uri.file( - path.join(resources.mediaFolder, "welcome_new_treeview.png"), - ), - actionsExplorer: vscode.Uri.file( - path.join(resources.mediaFolder, "welcome_actions_jupyter.png"), + exerciseDecorations: vscode.Uri.file( + path.join(resources.mediaFolder, "welcome_exercise_decorations.png"), ), tmcLogoFile: vscode.Uri.file(path.join(resources.mediaFolder, "TMC.png")), }, diff --git a/src/config/constants.ts b/src/config/constants.ts index 6ccb4c0e..c57f03ab 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -21,6 +21,23 @@ export const CLIENT_NAME = "vscode_plugin"; export const EXTENSION_ID = "moocfi.test-my-code"; export const OUTPUT_CHANNEL_NAME = "TestMyCode"; +/** + * Delay for notifications that offer a "remind me later" option. + */ +export const NOTIFICATION_DELAY = 30 * 60 * 1000; + +export const API_CACHE_LIFETIME = 5 * 60 * 1000; +export const CLI_PROCESS_TIMEOUT = 2 * 60 * 1000; +export const EXERCISE_CHECK_INTERVAL = 30 * 60 * 1000; + +/** Minimum time that should be waited between submission attempts. */ +export const MINIMUM_SUBMISSION_INTERVAL = 5 * 1000; + +export const LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER = 1; +export const LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER = + LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER; +export const LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER = 0; + export const HIDE_META_FILES = { "**/__pycache__": true, "**/.available_points.json": true, @@ -31,6 +48,7 @@ export const HIDE_META_FILES = { "**/.tmcproject.json": true, "**/.tmc.json": true, "**/.tmc.lock": true, + "**/.tmc_test_results.hmac.sha256": true, }; export const SHOW_META_FILES = { @@ -43,6 +61,7 @@ export const SHOW_META_FILES = { "**/.tmcproject.json": false, "**/.tmc.json": false, "**/.tmc.lock": false, + "**/.tmc_test_results.hmac.sha256": false, }; export const WATCHER_EXCLUDE = { @@ -53,25 +72,15 @@ export const WATCHER_EXCLUDE = { export const WORKSPACE_SETTINGS = { folders: [{ path: ".tmc" }], settings: { - "workbench.editor.closeOnFileDelete": true, + "explorer.decorations.colors": false, "files.autoSave": "onFocusChange", "files.exclude": { ...HIDE_META_FILES }, "files.watcherExclude": { ...WATCHER_EXCLUDE }, + "problems.decorations.enabled": false, + "workbench.editor.closeOnFileDelete": true, }, }; -/** - * Delay for notifications that offer a "remind me later" option. - */ -export const NOTIFICATION_DELAY = 30 * 60 * 1000; - -export const API_CACHE_LIFETIME = 5 * 60 * 1000; -export const CLI_PROCESS_TIMEOUT = 2 * 60 * 1000; -export const EXERCISE_CHECK_INTERVAL = 30 * 60 * 1000; - -/** Minimum time that should be waited between submission attempts. */ -export const MINIMUM_SUBMISSION_INTERVAL = 5 * 1000; - export const EMPTY_HTML_DOCUMENT = ``; /** diff --git a/src/config/settings.ts b/src/config/settings.ts index 9a2f10fe..316b12e6 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -1,17 +1,11 @@ -import { Ok, Result } from "ts-results"; import * as vscode from "vscode"; -import Storage, { - ExtensionSettings as SerializedExtensionSettings, - SessionState, -} from "../api/storage"; -import { isCorrectWorkspaceOpen } from "../utils"; +import Storage, { SessionState } from "../api/storage"; import { Logger, LogLevel } from "../utils/logger"; -import { HIDE_META_FILES, SHOW_META_FILES, WATCHER_EXCLUDE } from "./constants"; -import Resources from "./resources"; -import { ExtensionSettingsData } from "./types"; - +/** + * @deprecated Default values are now implemented in package.json / VSCode settings. + */ export interface ExtensionSettings { downloadOldSubmission: boolean; hideMetaFiles: boolean; @@ -21,185 +15,139 @@ export interface ExtensionSettings { } /** - * Settings class communicates changes to persistent storage and manages TMC - * Workspace.code-workspace settings. Workspace settings will only be updated when it is open. + * Class to manage VSCode setting changes and trigger events based on changes. + * Remove Storage dependency once 3.0 major release is being done, as then + * we do not need to be backwards compatible. * - * Perhaps TODO: Read and Write the .code-workspace file without using vscode premade functions for - * workspace, because they require the workspace to be open. Currently this approach works, because - * extension settings are saved to storage and VSCode restarts when our workspace is opened by the - * extension. + * Handle multi-root workspace changes by creating callbacks in extension.ts, + * so that we can test and don't need workspaceManager dependency. */ -export default class Settings { - private static readonly _defaultSettings: ExtensionSettings = { - downloadOldSubmission: true, - hideMetaFiles: true, - insiderVersion: false, - logLevel: LogLevel.Errors, - updateExercisesAutomatically: true, - }; +export default class Settings implements vscode.Disposable { + private _onChangeHideMetaFiles?: (value: boolean) => void; + private _onChangeDownloadOldSubmission?: (value: boolean) => void; + private _onChangeUpdateExercisesAutomatically?: (value: boolean) => void; + /** + * @deprecated Storage dependency should be removed when major 3.0 release. + */ private readonly _storage: Storage; - private readonly _resources: Resources; - - private _settings: ExtensionSettings; private _state: SessionState; + private _disposables: vscode.Disposable[]; - constructor(storage: Storage, resources: Resources) { + constructor(storage: Storage) { this._storage = storage; - this._resources = resources; - const storedSettings = storage.getExtensionSettings(); - this._settings = storedSettings - ? Settings._deserializeExtensionSettings(storedSettings) - : Settings._defaultSettings; this._state = storage.getSessionState() ?? {}; + this._disposables = [ + vscode.workspace.onDidChangeConfiguration(async (event) => { + if (event.affectsConfiguration("testMyCode.logLevel")) { + const value = vscode.workspace + .getConfiguration("testMyCode") + .get("logLevel", LogLevel.Errors); + Logger.configure(value); + } + + // Workspace settings + if (event.affectsConfiguration("testMyCode.hideMetaFiles")) { + const value = this._getWorkspaceSettingValue("hideMetaFiles"); + this._onChangeHideMetaFiles?.(value); + } + if (event.affectsConfiguration("testMyCode.downloadOldSubmission")) { + const value = this._getWorkspaceSettingValue("downloadOldSubmission"); + this._onChangeDownloadOldSubmission?.(value); + } + if (event.affectsConfiguration("testMyCode.updateExercisesAutomatically")) { + const value = this._getWorkspaceSettingValue("updateExercisesAutomatically"); + this._onChangeUpdateExercisesAutomatically?.(value); + } + await this.updateExtensionSettingsToStorage(); + }), + ]; } - public async verifyWorkspaceSettingsIntegrity(): Promise { - const workspace = vscode.workspace.name; - if (workspace && isCorrectWorkspaceOpen(this._resources, workspace.split(" ")[0])) { - Logger.log("TMC Workspace open, verifying workspace settings integrity."); - await this._setFilesExcludeInWorkspace(this._settings.hideMetaFiles); - await this._verifyWatcherPatternExclusion(); - } + public set onChangeDownloadOldSubmission(callback: (value: boolean) => void) { + this._onChangeDownloadOldSubmission = callback; } - /** - * Update extension settings to storage. - * @param settings ExtensionSettings object - */ - public async updateExtensionSettingsToStorage(settings: ExtensionSettings): Promise { - await this._storage.updateExtensionSettings(settings); + public set onChangeHideMetaFiles(callback: (value: boolean) => void) { + this._onChangeHideMetaFiles = callback; + } + + public set onChangeUpdateExercisesAutomatically(callback: (value: boolean) => void) { + this._onChangeUpdateExercisesAutomatically = callback; + } + + public dispose(): void { + this._disposables.forEach((x) => x.dispose()); } /** - * Updates individual setting for user and adds them to user storage. - * - * @param {ExtensionSettingsData} data ExtensionSettingsData object, for example { setting: - * 'dataPath', value: '~/newpath' } + * @deprecated Storage dependency should be removed when major 3.0 release. */ - public async updateSetting(data: ExtensionSettingsData): Promise { - switch (data.setting) { - case "downloadOldSubmission": - this._settings.downloadOldSubmission = data.value; - break; - case "hideMetaFiles": - this._settings.hideMetaFiles = data.value; - this._setFilesExcludeInWorkspace(data.value); - break; - case "insiderVersion": - this._settings.insiderVersion = data.value; - break; - case "logLevel": - this._settings.logLevel = data.value; - break; - case "updateExercisesAutomatically": - this._settings.updateExercisesAutomatically = data.value; - break; - } - Logger.log("Updated settings data", data); - await this.updateExtensionSettingsToStorage(this._settings); + public async updateExtensionSettingsToStorage(): Promise { + const settings: ExtensionSettings = { + downloadOldSubmission: this._getUserSettingValue("downloadOldSubmission"), + hideMetaFiles: this._getUserSettingValue("hideMetaFiles"), + updateExercisesAutomatically: this._getUserSettingValue("updateExercisesAutomatically"), + logLevel: + vscode.workspace.getConfiguration().get("testMyCode.logLevel") ?? LogLevel.Errors, + insiderVersion: this._getUserSettingValue("insiderVersion"), + }; + await this._storage.updateExtensionSettings(settings); } public getLogLevel(): LogLevel { - return this._settings.logLevel; + return vscode.workspace + .getConfiguration("testMyCode") + .get("logLevel", LogLevel.Errors); } public getDownloadOldSubmission(): boolean { - return this._settings.downloadOldSubmission; + return this._getWorkspaceSettingValue("downloadOldSubmission"); } public getAutomaticallyUpdateExercises(): boolean { - return this._settings.updateExercisesAutomatically; - } - - /** - * Gets the extension settings from storage. - * - * @returns ExtensionSettings object or error - */ - public async getExtensionSettings(): Promise> { - return Ok(this._settings); - } - - /** - * Returns the section for the Workspace setting. If undefined, returns all settings. - * @param section A dot-separated identifier. - */ - public getWorkspaceSettings(section?: string): vscode.WorkspaceConfiguration | undefined { - const workspace = vscode.workspace.name?.split(" ")[0]; - if (workspace && isCorrectWorkspaceOpen(this._resources, workspace)) { - return vscode.workspace.getConfiguration( - section, - vscode.Uri.file(this._resources.getWorkspaceFilePath(workspace)), - ); - } + return this._getWorkspaceSettingValue("updateExercisesAutomatically"); } public isInsider(): boolean { - return this._settings.insiderVersion; + return vscode.workspace + .getConfiguration("testMyCode") + .get("insiderVersion", false); } - private static _deserializeExtensionSettings( - settings: SerializedExtensionSettings, - ): ExtensionSettings { - let logLevel: LogLevel = LogLevel.Errors; - switch (settings.logLevel) { - case "errors": - logLevel = LogLevel.Errors; - break; - case "none": - logLevel = LogLevel.None; - break; - case "verbose": - logLevel = LogLevel.Verbose; - break; - } - - return { - ...settings, - logLevel, - }; + public async configureIsInsider(value: boolean): Promise { + await vscode.workspace.getConfiguration("testMyCode").update("insiderVersion", value, true); + await this.updateExtensionSettingsToStorage(); } /** - * Updates files.exclude values in TMC Workspace.code-workspace. - * Keeps all user/workspace defined excluding patterns. - * @param hide true to hide meta files in TMC workspace. - */ - private async _setFilesExcludeInWorkspace(hide: boolean): Promise { - const value = hide ? HIDE_META_FILES : SHOW_META_FILES; - await this._updateWorkspaceSetting("files.exclude", value); - } - - /** - * Updates a section for the TMC Workspace.code-workspace file, if the workspace is open. - * @param section Configuration name, supports dotted names. - * @param value The new value + * Used to fetch boolean values from VSCode settings API Workspace scope + * + * workspaceValue is undefined in multi-root workspace if it matches defaultValue + * We want to "force" the value in the multi-root workspace, because then + * the workspace scope > user scope. */ - private async _updateWorkspaceSetting(section: string, value: unknown): Promise { - const workspace = vscode.workspace.name?.split(" ")[0]; - if (workspace && isCorrectWorkspaceOpen(this._resources, workspace)) { - const oldValue = this.getWorkspaceSettings(section); - let newValue = value; - if (value instanceof Object) { - newValue = { ...oldValue, ...value }; - } - await vscode.workspace - .getConfiguration( - undefined, - vscode.Uri.file(this._resources.getWorkspaceFilePath(workspace)), - ) - .update(section, newValue, vscode.ConfigurationTarget.Workspace); + private _getWorkspaceSettingValue(section: string): boolean { + const configuration = vscode.workspace.getConfiguration("testMyCode"); + const scopeSettings = configuration.inspect(section); + if (scopeSettings?.workspaceValue === undefined) { + return !!scopeSettings?.defaultValue; + } else { + return scopeSettings.workspaceValue; } } /** - * Makes sure that folders and its contents aren't deleted by our watcher. - * .vscode folder needs to be unwatched, otherwise adding settings to WorkspaceFolder level - * doesn't work. For example defining Python interpreter for the Exercise folder. + * Used to fetch boolean values from VSCode settings API User Scope */ - private async _verifyWatcherPatternExclusion(): Promise { - await this._updateWorkspaceSetting("files.watcherExclude", { ...WATCHER_EXCLUDE }); + private _getUserSettingValue(section: string): boolean { + const configuration = vscode.workspace.getConfiguration("testMyCode"); + const scopeSettings = configuration.inspect(section); + if (scopeSettings?.globalValue === undefined) { + return !!scopeSettings?.defaultValue; + } else { + return scopeSettings.globalValue; + } } } diff --git a/src/config/userdata.ts b/src/config/userdata.ts index 736fd193..830d93a8 100644 --- a/src/config/userdata.ts +++ b/src/config/userdata.ts @@ -50,6 +50,19 @@ export class UserData { } } + public async setExerciseAsPassed(courseSlug: string, exerciseName: string): Promise { + for (const course of this._courses.values()) { + if (course.name === courseSlug) { + const exercise = course.exercises.find((x) => x.name === exerciseName); + if (exercise) { + exercise.passed = true; + await this._updatePersistentData(); + break; + } + } + } + } + public addCourse(data: LocalCourseData): void { if (this._courses.has(data.id)) { throw new Error("Trying to add an already existing course"); diff --git a/src/extension.ts b/src/extension.ts index fab65563..ea04fea6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,7 +3,9 @@ import { createIs } from "typescript-is"; import * as vscode from "vscode"; import { checkForCourseUpdates, refreshLocalExercises } from "./actions"; +import { ActionContext } from "./actions/types"; import Dialog from "./api/dialog"; +import ExerciseDecorationProvider from "./api/exerciseDecorationProvider"; import Storage from "./api/storage"; import TMC from "./api/tmc"; import WorkspaceManager from "./api/workspaceManager"; @@ -75,6 +77,7 @@ export async function activate(context: vscode.ExtensionContext): Promise storage, dialog, tmc, + vscode.workspace.getConfiguration(), ); if (migrationResult.err) { if (migrationResult.val instanceof HaltForReloadError) { @@ -95,7 +98,7 @@ export async function activate(context: vscode.ExtensionContext): Promise } const tmcDataPath = dataPathResult.val; - const workspaceFileFolder = path.join(context.globalStoragePath, "workspaces"); + const workspaceFileFolder = path.join(context.globalStorageUri.fsPath, "workspaces"); const resourcesResult = await init.resourceInitialization( context, storage, @@ -108,8 +111,10 @@ export async function activate(context: vscode.ExtensionContext): Promise } const resources = resourcesResult.val; - const settings = new Settings(storage, resources); - await settings.verifyWorkspaceSettingsIntegrity(); + + const settings = new Settings(storage); + context.subscriptions.push(settings); + Logger.configure(settings.getLogLevel()); const ui = new UI(context, resources, vscode.window.createStatusBarItem()); @@ -140,11 +145,14 @@ export async function activate(context: vscode.ExtensionContext): Promise context.subscriptions.push(workspaceManager); if (workspaceManager.activeCourse) { await vscode.commands.executeCommand("setContext", "test-my-code:WorkspaceActive", true); + await workspaceManager.verifyWorkspaceSettingsIntegrity(); } const temporaryWebviewProvider = new TemporaryWebviewProvider(resources, ui); - const actionContext = { + const exerciseDecorationProvider = new ExerciseDecorationProvider(userData, workspaceManager); + const actionContext: ActionContext = { dialog, + exerciseDecorationProvider, resources, settings, temporaryWebviewProvider, @@ -162,6 +170,11 @@ export async function activate(context: vscode.ExtensionContext): Promise init.registerUiActions(actionContext); init.registerCommands(context, actionContext); + init.registerSettingsCallbacks(actionContext); + + context.subscriptions.push( + vscode.window.registerFileDecorationProvider(exerciseDecorationProvider), + ); if (authenticated) { vscode.commands.executeCommand("tmc.updateExercises", "silent"); diff --git a/src/init/commands.ts b/src/init/commands.ts index fbce43e1..102746fd 100644 --- a/src/init/commands.ts +++ b/src/init/commands.ts @@ -48,6 +48,10 @@ export function registerCommands( commands.addNewCourse(actionContext), ), + vscode.commands.registerCommand("tmc.changeTmcDataPath", async () => + commands.changeTmcDataPath(actionContext), + ), + vscode.commands.registerCommand( "tmc.cleanExercise", async (resource: vscode.Uri | undefined) => @@ -125,7 +129,7 @@ export function registerCommands( }), vscode.commands.registerCommand("tmc.openSettings", async () => { - actions.openSettings(actionContext); + vscode.commands.executeCommand("workbench.action.openSettings", "TestMyCode"); }), vscode.commands.registerCommand("tmc.openTMCExercisesFolder", async () => { diff --git a/src/init/index.ts b/src/init/index.ts index 3eddf205..e6789332 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -2,3 +2,4 @@ export * from "./commands"; export * from "./resources"; export * from "./ui"; export * from "./downloadCorrectLangsVersion"; +export * from "./settings"; diff --git a/src/init/settings.ts b/src/init/settings.ts new file mode 100644 index 00000000..ef683275 --- /dev/null +++ b/src/init/settings.ts @@ -0,0 +1,18 @@ +import { ActionContext } from "../actions/types"; + +export async function registerSettingsCallbacks(actionContext: ActionContext): Promise { + const { settings, workspaceManager } = actionContext; + settings.onChangeHideMetaFiles = async (value: boolean): Promise => { + await workspaceManager.updateWorkspaceSetting("testMyCode.hideMetaFiles", value); + await workspaceManager.excludeMetaFilesInWorkspace(value); + }; + settings.onChangeDownloadOldSubmission = async (value: boolean): Promise => { + await workspaceManager.updateWorkspaceSetting("testMyCode.downloadOldSubmission", value); + }; + settings.onChangeUpdateExercisesAutomatically = async (value: boolean): Promise => { + await workspaceManager.updateWorkspaceSetting( + "testMyCode.updateExercisesAutomatically", + value, + ); + }; +} diff --git a/src/init/ui.ts b/src/init/ui.ts index 29483728..b62c4de8 100644 --- a/src/init/ui.ts +++ b/src/init/ui.ts @@ -1,4 +1,3 @@ -import du = require("du"); import { Result } from "ts-results"; import * as vscode from "vscode"; @@ -9,15 +8,15 @@ import { displayUserCourses, downloadOrUpdateExercises, login, - moveExtensionDataPath, openExercises, openWorkspace, refreshLocalExercises, removeCourse, + selectOrganizationAndCourse, updateCourse, } from "../actions"; import { ActionContext } from "../actions/types"; -import { formatSizeInBytes, Logger, LogLevel } from "../utils/"; +import { Logger } from "../utils/"; /** * Registers the various actions and handlers required for the user interface to function. @@ -26,7 +25,7 @@ import { formatSizeInBytes, Logger, LogLevel } from "../utils/"; * @param tmc The TMC API object */ export function registerUiActions(actionContext: ActionContext): void { - const { dialog, ui, resources, settings, userData, visibilityGroups } = actionContext; + const { dialog, ui, settings, userData, visibilityGroups } = actionContext; Logger.log("Initializing UI Actions"); // Register UI actions @@ -98,6 +97,13 @@ export function registerUiActions(actionContext: ActionContext): void { displayUserCourses(actionContext); }); + ui.webview.registerHandler("changeTmcDataPath", async (msg: { type?: "changeTmcDataPath" }) => { + if (!msg.type) { + return; + } + await vscode.commands.executeCommand("tmc.changeTmcDataPath"); + }); + ui.webview.registerHandler( "clearNewExercises", (msg: { type?: "clearNewExercises"; courseId?: number }) => { @@ -183,7 +189,15 @@ export function registerUiActions(actionContext: ActionContext): void { }, ); ui.webview.registerHandler("addCourse", async () => { - const result = await addNewCourse(actionContext); + const orgAndCourse = await selectOrganizationAndCourse(actionContext); + if (orgAndCourse.err) { + return dialog.errorNotification( + `Failed to add new course: ${orgAndCourse.val.message}`, + ); + } + const organization = orgAndCourse.val.organization; + const course = orgAndCourse.val.course; + const result = await addNewCourse(actionContext, organization, course); if (result.err) { dialog.errorNotification(`Failed to add new course: ${result.val.message}`); } @@ -264,71 +278,6 @@ export function registerUiActions(actionContext: ActionContext): void { } }, ); - ui.webview.registerHandler("changeTmcDataPath", async (msg: { type?: "changeTmcDataPath" }) => { - if (!msg.type) { - return; - } - - const old = resources.projectsDirectory; - const options: vscode.OpenDialogOptions = { - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - openLabel: "Select folder", - }; - const newPath = (await vscode.window.showOpenDialog(options))?.[0]; - if (newPath && old) { - const res = await dialog.progressNotification( - "Moving projects directory...", - (progress) => { - return moveExtensionDataPath(actionContext, newPath, (update) => - progress.report(update), - ); - }, - ); - if (res.ok) { - Logger.log(`Moved workspace folder from ${old} to ${newPath.fsPath}`); - dialog.notification(`TMC Data was successfully moved to ${newPath.fsPath}`, [ - "OK", - (): void => {}, - ]); - } else { - dialog.errorNotification(res.val.message, res.val); - } - ui.webview.postMessage({ - command: "setTmcDataFolder", - diskSize: formatSizeInBytes(await du(resources.projectsDirectory)), - path: resources.projectsDirectory, - }); - } - }); - - ui.webview.registerHandler( - "changeLogLevel", - async (msg: { type?: "changeLogLevel"; data?: LogLevel }) => { - if (!(msg.type && msg.data)) { - return; - } - await settings.updateSetting({ setting: "logLevel", value: msg.data }); - Logger.configure(msg.data); - ui.webview.postMessage({ command: "setLogLevel", level: msg.data }); - }, - ); - - ui.webview.registerHandler( - "hideMetaFiles", - async (msg: { type?: "hideMetaFiles"; data?: boolean }) => { - if (!(msg.type && msg.data !== undefined)) { - return; - } - await settings.updateSetting({ setting: "hideMetaFiles", value: msg.data }); - ui.webview.postMessage({ - command: "setBooleanSetting", - setting: "hideMetaFiles", - enabled: msg.data, - }); - }, - ); ui.webview.registerHandler( "insiderStatus", @@ -336,12 +285,7 @@ export function registerUiActions(actionContext: ActionContext): void { if (!(msg.type && msg.data !== undefined)) { return; } - await settings.updateSetting({ setting: "insiderVersion", value: msg.data }); - ui.webview.postMessage({ - command: "setBooleanSetting", - setting: "insider", - enabled: msg.data, - }); + await settings.configureIsInsider(!!msg.data); }, ); @@ -358,47 +302,4 @@ export function registerUiActions(actionContext: ActionContext): void { } vscode.commands.executeCommand("workbench.action.openExtensionLogsFolder"); }); - - ui.webview.registerHandler("openEditorDirection", (msg: { type?: "openEditorDirection" }) => { - if (!msg.type) { - return; - } - const search = "workbench.editor.openSideBySideDirection"; - // openWorkspaceSettings doesn't take search params: - // https://github.com/microsoft/vscode/issues/90086 - vscode.commands.executeCommand("workbench.action.openSettings", search); - }); - - ui.webview.registerHandler( - "downloadOldSubmissionSetting", - async (msg: { type?: "downloadOldSubmissionSetting"; data?: boolean }) => { - if (!(msg.type && msg.data !== undefined)) { - return; - } - await settings.updateSetting({ setting: "downloadOldSubmission", value: msg.data }); - ui.webview.postMessage({ - command: "setBooleanSetting", - setting: "downloadOldSubmission", - enabled: msg.data, - }); - }, - ); - - ui.webview.registerHandler( - "updateExercisesAutomaticallySetting", - async (msg: { type?: "updateExercisesAutomaticallySetting"; data?: boolean }) => { - if (!(msg.type && msg.data !== undefined)) { - return; - } - await settings.updateSetting({ - setting: "updateExercisesAutomatically", - value: msg.data, - }); - ui.webview.postMessage({ - command: "setBooleanSetting", - setting: "updateExercisesAutomatically", - enabled: msg.data, - }); - }, - ); } diff --git a/src/migrate/index.ts b/src/migrate/index.ts index 4a601c59..bf2cea0c 100644 --- a/src/migrate/index.ts +++ b/src/migrate/index.ts @@ -31,6 +31,7 @@ export async function migrateExtensionDataFromPreviousVersions( storage: Storage, dialog: Dialog, tmc: TMC, + settings: vscode.WorkspaceConfiguration, ): Promise> { const memento = context.globalState; @@ -46,7 +47,7 @@ export async function migrateExtensionDataFromPreviousVersions( } try { - const migratedExtensionSettings = await migrateExtensionSettings(memento); + const migratedExtensionSettings = await migrateExtensionSettings(memento, settings); const migratedSessionState = migrateSessionState(memento); const migratedUserData = migrateUserData(memento); diff --git a/src/migrate/migrateExerciseData.ts b/src/migrate/migrateExerciseData.ts index 55385d6e..d99b4392 100644 --- a/src/migrate/migrateExerciseData.ts +++ b/src/migrate/migrateExerciseData.ts @@ -151,7 +151,7 @@ async function exerciseDataFromV0toV1( for (const key of Object.keys(closedExercises)) { const closeExercisesResult = await tmc.setSetting( `closed-exercises-for:${key}`, - JSON.stringify(closedExercises[key]), + closedExercises[key], ); if (closeExercisesResult.err) { Logger.error("Failed to migrate status of closed exercises.", closeExercisesResult.val); diff --git a/src/migrate/migrateExtensionSettings.ts b/src/migrate/migrateExtensionSettings.ts index b4daeb83..9c2e6cf0 100644 --- a/src/migrate/migrateExtensionSettings.ts +++ b/src/migrate/migrateExtensionSettings.ts @@ -1,11 +1,15 @@ import { createIs } from "typescript-is"; import * as vscode from "vscode"; +import { semVerCompare } from "../utils"; + import { MigratedData } from "./types"; import validateData from "./validateData"; const EXTENSION_SETTINGS_KEY_V0 = "extensionSettings"; const EXTENSION_SETTINGS_KEY_V1 = "extension-settings-v1"; +const UNSTABLE_EXTENSION_VERSION_KEY = "extensionVersion"; +const SESSION_STATE_KEY_V1 = "session-state-v1"; export enum LogLevelV0 { None = "none", @@ -34,6 +38,10 @@ export interface ExtensionSettingsV1 { updateExercisesAutomatically: boolean; } +interface SessionStatePartial { + extensionVersion: string | undefined; +} + function logLevelV0toV1(logLevel: LogLevelV0): LogLevelV1 { switch (logLevel) { case LogLevelV0.Debug: @@ -60,8 +68,36 @@ async function extensionDataFromV0toV1( }; } +async function migrateSettingsToVSCodeSettingsAPI( + memento: vscode.Memento, + storageSettings: ExtensionSettingsV1, + settings: vscode.WorkspaceConfiguration, +): Promise { + let version = memento.get(UNSTABLE_EXTENSION_VERSION_KEY); + if (!version) { + version = memento.get(SESSION_STATE_KEY_V1)?.extensionVersion; + } + const compareVersions = semVerCompare(version ?? "0.0.0", "2.1.0", "minor"); + if (!compareVersions || compareVersions < 0) { + await settings.update( + "testMyCode.downloadOldSubmission", + storageSettings.downloadOldSubmission, + true, + ); + await settings.update("testMyCode.hideMetaFiles", storageSettings.hideMetaFiles, true); + await settings.update( + "testMyCode.updateExercisesAutomatically", + storageSettings.updateExercisesAutomatically, + true, + ); + await settings.update("testMyCode.insiderVersion", storageSettings.insiderVersion, true); + await settings.update("testMyCode.logLevel", storageSettings.logLevel, true); + } +} + export default async function migrateExtensionSettings( memento: vscode.Memento, + settings: vscode.WorkspaceConfiguration, ): Promise> { const obsoleteKeys: string[] = []; const dataV0 = validateData( @@ -76,5 +112,9 @@ export default async function migrateExtensionSettings( ? await extensionDataFromV0toV1(dataV0) : validateData(memento.get(EXTENSION_SETTINGS_KEY_V1), createIs()); + if (dataV1) { + await migrateSettingsToVSCodeSettingsAPI(memento, dataV1, settings); + } + return { data: dataV1, obsoleteKeys }; } diff --git a/src/migrate/migrateUserData.ts b/src/migrate/migrateUserData.ts index 51ea7577..9a9a8a08 100644 --- a/src/migrate/migrateUserData.ts +++ b/src/migrate/migrateUserData.ts @@ -1,6 +1,13 @@ import { createIs } from "typescript-is"; import * as vscode from "vscode"; +import { LocalCourseData } from "../api/storage"; +import { + LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, + LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER, + LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER, +} from "../config/constants"; + import { MigratedData } from "./types"; import validateData from "./validateData"; @@ -39,6 +46,8 @@ export interface LocalCourseDataV1 { organization: string; exercises: Array<{ id: number; + awardedPoints?: number; + availablePoints?: number; name: string; deadline: string | null; passed: boolean; @@ -95,9 +104,25 @@ function courseDataFromV0ToV1( }); } +export function resolveMissingFields(localCourseData: LocalCourseDataV1[]): LocalCourseData[] { + return localCourseData.map((course) => { + const exercises = course.exercises.map((x) => { + const resolvedAwardedPoints = x.passed + ? LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER + : LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER; + return { + ...x, + availablePoints: x.availablePoints ?? LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, + awardedPoints: x.awardedPoints ?? resolvedAwardedPoints, + }; + }); + return { ...course, exercises }; + }); +} + export default function migrateUserData( memento: vscode.Memento, -): MigratedData<{ courses: LocalCourseDataV1[] }> { +): MigratedData<{ courses: LocalCourseData[] }> { const obsoleteKeys: string[] = []; const dataV0 = validateData( memento.get(USER_DATA_KEY_V0), @@ -111,5 +136,7 @@ export default function migrateUserData( ? { courses: courseDataFromV0ToV1(dataV0.courses, memento) } : validateData(memento.get(USER_DATA_KEY_V1), createIs<{ courses: LocalCourseDataV1[] }>()); - return { data: dataV1, obsoleteKeys }; + const data = dataV1 ? { ...dataV1, courses: resolveMissingFields(dataV1?.courses) } : undefined; + + return { data, obsoleteKeys }; } diff --git a/src/test-integration/tmc_langs_cli.spec.ts b/src/test-integration/tmc_langs_cli.spec.ts new file mode 100644 index 00000000..0001f550 --- /dev/null +++ b/src/test-integration/tmc_langs_cli.spec.ts @@ -0,0 +1,548 @@ +import { expect } from "chai"; +import * as cp from "child_process"; +import { sync as delSync } from "del"; +import * as fs from "fs-extra"; +import { first } from "lodash"; +import * as path from "path"; +import * as kill from "tree-kill"; +import { Result } from "ts-results"; + +import TMC from "../api/tmc"; +import { SubmissionFeedback } from "../api/types"; +import { CLIENT_NAME, TMC_LANGS_VERSION } from "../config/constants"; +import { AuthenticationError, AuthorizationError, BottleneckError, RuntimeError } from "../errors"; +import { getLangsCLIForPlatform, getPlatform } from "../utils/"; + +// __dirname is the dist folder when built. +const PROJECT_ROOT = path.join(__dirname, ".."); +const ARTIFACT_FOLDER = path.join(PROJECT_ROOT, "test-artifacts"); + +// Use CLI from backend folder to run tests. +const BACKEND_FOLDER = path.join(PROJECT_ROOT, "backend"); +const CLI_PATH = path.join(BACKEND_FOLDER, "cli"); +const CLI_FILE = path.join(CLI_PATH, getLangsCLIForPlatform(getPlatform(), TMC_LANGS_VERSION)); +const FEEDBACK_URL = "http://localhost:4001/feedback"; + +// Example backend credentials +const USERNAME = "TestMyExtension"; +const PASSWORD = "hunter2"; + +// Config dir name must follow conventions mandated by TMC-langs. +const CLIENT_CONFIG_DIR_NAME = `tmc-${CLIENT_NAME}`; + +suite("tmc langs cli spec", function () { + let server: cp.ChildProcess | undefined; + + suiteSetup(async function () { + this.timeout(30000); + server = await startServer(); + }); + + let testDir: string; + + setup(function () { + let testDirName = this.currentTest?.fullTitle().replace(/\s/g, "_"); + if (!testDirName) throw new Error("Illegal function call."); + if (testDirName?.length > 72) { + testDirName = + testDirName.substring(0, 40) + + ".." + + testDirName.substring(testDirName.length - 30); + } + testDir = path.join(ARTIFACT_FOLDER, testDirName); + }); + + suite("authenticated user", function () { + let configDir: string; + let onLoggedInCalls: number; + let onLoggedOutCalls: number; + let projectsDir: string; + let tmc: TMC; + + setup(function () { + configDir = path.join(testDir, CLIENT_CONFIG_DIR_NAME); + writeCredentials(configDir); + onLoggedInCalls = 0; + onLoggedOutCalls = 0; + projectsDir = setupProjectsDir(configDir, path.join(testDir, "tmcdata")); + tmc = new TMC(CLI_FILE, CLIENT_NAME, "test", { + cliConfigDir: testDir, + }); + tmc.on("login", () => onLoggedInCalls++); + tmc.on("logout", () => onLoggedOutCalls++); + }); + + test("should not be able to re-authenticate", async function () { + const result = await tmc.authenticate(USERNAME, PASSWORD); + expect(result.val).to.be.instanceOf(AuthenticationError); + }); + + test("should be able to deauthenticate", async function () { + await unwrapResult(tmc.deauthenticate()); + expect(onLoggedOutCalls).to.be.equal(1); + + const result = await unwrapResult(tmc.isAuthenticated()); + expect(result).to.be.false; + + expect(onLoggedInCalls).to.be.equal(0); + }); + + test("should be able to read and change settings", async function () { + const key = "test-value"; + const isString = (object: unknown): object is string => typeof object === "string"; + + const result1 = await unwrapResult(tmc.getSetting(key, isString)); + expect(result1).to.be.undefined; + + await unwrapResult(tmc.setSetting(key, "yes no yes yes")); + const result2 = await unwrapResult(tmc.getSetting(key, isString)); + expect(result2).to.be.equal("yes no yes yes"); + + await unwrapResult(tmc.unsetSetting(key)); + const result3 = await unwrapResult(tmc.getSetting(key, isString)); + expect(result3).to.be.undefined; + + await unwrapResult(tmc.setSetting(key, "foo bar biz baz")); + await unwrapResult(tmc.resetSettings()); + const result4 = await unwrapResult(tmc.getSetting(key, isString)); + expect(result4).to.be.undefined; + }); + + test("should be able to download an existing exercise", async function () { + const result = await tmc.downloadExercises([1], true, () => {}); + result.err && expect.fail(`Expected operation to succeed: ${result.val.message}`); + }).timeout(10000); + + // Ids missing from the server are missing from the response. + test.skip("should not be able to download a non-existent exercise", async function () { + const downloads = (await tmc.downloadExercises([404], true, () => {})).unwrap(); + expect(downloads.failed?.length).to.be.equal(1); + }); + + test("should get existing api data", async function () { + const data = (await tmc.getCourseData(0)).unwrap(); + expect(data.details.name).to.be.equal("python-course"); + expect(data.exercises.length).to.be.equal(2); + expect(data.settings.name).to.be.equal("python-course"); + + const details = (await tmc.getCourseDetails(0)).unwrap().course; + expect(details.id).to.be.equal(0); + expect(details.name).to.be.equal("python-course"); + + const exercises = (await tmc.getCourseExercises(0)).unwrap(); + expect(exercises.length).to.be.equal(2); + + const settings = (await tmc.getCourseSettings(0)).unwrap(); + expect(settings.name).to.be.equal("python-course"); + + const courses = (await tmc.getCourses("test")).unwrap(); + expect(courses.length).to.be.equal(1); + expect(courses.some((x) => x.name === "python-course")).to.be.true; + + const exercise = (await tmc.getExerciseDetails(1)).unwrap(); + expect(exercise.exercise_name).to.be.equal("part01-01_passing_exercise"); + + const submissions = (await tmc.getOldSubmissions(1)).unwrap(); + expect(submissions.length).to.be.greaterThan(0); + + const organization = (await tmc.getOrganization("test")).unwrap(); + expect(organization.slug).to.be.equal("test"); + expect(organization.name).to.be.equal("Test Organization"); + + const organizations = (await tmc.getOrganizations()).unwrap(); + expect(organizations.length).to.be.equal(1, "Expected to get one organization."); + }); + + test("should encounter errors when trying to get non-existing api data", async function () { + const dataResult = await tmc.getCourseData(404); + expect(dataResult.val).to.be.instanceOf(RuntimeError); + + const detailsResult = await tmc.getCourseDetails(404); + expect(detailsResult.val).to.be.instanceOf(RuntimeError); + + const exercisesResult = await tmc.getCourseExercises(404); + expect(exercisesResult.val).to.be.instanceOf(RuntimeError); + + const settingsResult = await tmc.getCourseSettings(404); + expect(settingsResult.val).to.be.instanceOf(RuntimeError); + + const coursesResult = await tmc.getCourses("404"); + expect(coursesResult.val).to.be.instanceOf(RuntimeError); + + const exerciseResult = await tmc.getExerciseDetails(404); + expect(exerciseResult.val).to.be.instanceOf(RuntimeError); + + const submissionsResult = await tmc.getOldSubmissions(404); + expect(submissionsResult.val).to.be.instanceOf(RuntimeError); + + const result = await tmc.getOrganization("404"); + expect(result.val).to.be.instanceOf(RuntimeError); + }); + + test("should be able to give feedback", async function () { + const feedback: SubmissionFeedback = { + status: [{ question_id: 0, answer: "42" }], + }; + await unwrapResult(tmc.submitSubmissionFeedback(FEEDBACK_URL, feedback)); + }); + + suite("with a local exercise", function () { + this.timeout(20000); + + let exercisePath: string; + + setup(async function () { + delSync(projectsDir, { force: true }); + const result = (await tmc.downloadExercises([1], true, () => {})).unwrap(); + exercisePath = result.downloaded[0].path; + }); + + test("should be able to clean the exercise", async function () { + await unwrapResult(tmc.clean(exercisePath)); + }); + + test("should be able to list local exercises", async function () { + const result = await unwrapResult(tmc.listLocalCourseExercises("python-course")); + expect(result.length).to.be.equal(1); + expect(first(result)?.["exercise-path"]).to.be.equal(exercisePath); + }); + + test("should be able to run tests for exercise", async function () { + const result = await unwrapResult(tmc.runTests(exercisePath)[0]); + expect(result.status).to.be.equal("PASSED"); + }); + + test("should be able to migrate the exercise to langs projects directory", async function () { + // By changing projects directory path, the exercise is no longer there. Therefore + // it can be "migrated". + projectsDir = setupProjectsDir(configDir, path.join(testDir, "tmcdata2")); + fs.emptyDirSync(projectsDir); + await unwrapResult( + tmc.migrateExercise("python-course", "abc123", 1, exercisePath, "hello-world"), + ); + }); + + test("should be able to move projects directory", async function () { + const newProjectsDir = path.resolve(projectsDir, "..", "tmcdata2"); + fs.emptyDirSync(newProjectsDir); + await unwrapResult(tmc.moveProjectsDirectory(newProjectsDir)); + }); + + test("should be able to check for exercise updates", async function () { + const result = await unwrapResult(tmc.checkExerciseUpdates()); + expect(result.length).to.be.equal(0); + }); + + test("should be able to save the exercise state and revert it to an old submission", async function () { + const submissions = await unwrapResult(tmc.getOldSubmissions(1)); + await unwrapResult(tmc.downloadOldSubmission(1, exercisePath, 0, true)); + + // State saving check is based on a side effect of making a new submission. + const newSubmissions = await unwrapResult(tmc.getOldSubmissions(1)); + expect(newSubmissions.length).to.be.equal(submissions.length + 1); + }); + + test("should be able to download an old submission without saving the current state", async function () { + const submissions = await unwrapResult(tmc.getOldSubmissions(1)); + await unwrapResult(tmc.downloadOldSubmission(1, exercisePath, 0, false)); + + // State saving check is based on a side effect of making a new submission. + const newSubmissions = await unwrapResult(tmc.getOldSubmissions(1)); + expect(newSubmissions.length).to.be.equal(submissions.length); + }); + + // Langs fails to remove folder on Windows CI + test.skip("should be able to save the exercise state and reset it to original template", async function () { + const submissions = await unwrapResult(tmc.getOldSubmissions(1)); + await unwrapResult(tmc.resetExercise(1, exercisePath, true)); + + // State saving check is based on a side effect of making a new submission. + const newSubmissions = await unwrapResult(tmc.getOldSubmissions(1)); + expect(newSubmissions.length).to.be.equal(submissions.length + 1); + }); + + // Langs fails to remove folder on Windows CI + test.skip("should be able to reset exercise without saving the current state", async function () { + const submissions = await unwrapResult(tmc.getOldSubmissions(1)); + await unwrapResult(tmc.resetExercise(1, exercisePath, false)); + + // State saving check is based on a side effect of making a new submission. + const newSubmissions = await unwrapResult(tmc.getOldSubmissions(1)); + expect(newSubmissions.length).to.be.equal(submissions.length); + }); + + test("should be able to submit the exercise for evaluation", async function () { + let url: string | undefined; + const results = await unwrapResult( + tmc.submitExerciseAndWaitForResults( + 1, + exercisePath, + undefined, + (x) => (url = x), + ), + ); + expect(results.status).to.be.equal("ok"); + !url && expect.fail("expected to receive submission url during submission."); + }); + + test("should encounter an error if trying to submit the exercise twice too soon", async function () { + const first = tmc.submitExerciseAndWaitForResults(1, exercisePath); + const second = tmc.submitExerciseAndWaitForResults(1, exercisePath); + const [, secondResult] = await Promise.all([first, second]); + expect(secondResult.val).to.be.instanceOf(BottleneckError); + }); + + test("should be able to submit the exercise to TMC-paste", async function () { + const pasteUrl = await unwrapResult(tmc.submitExerciseToPaste(1, exercisePath)); + expect(pasteUrl).to.include("localhost"); + }); + + test("should encounter an error if trying to submit to paste twice too soon", async function () { + const first = tmc.submitExerciseToPaste(1, exercisePath); + const second = tmc.submitExerciseToPaste(1, exercisePath); + const [, secondResult] = await Promise.all([first, second]); + expect(secondResult.val).to.be.instanceOf(BottleneckError); + }); + }); + + suite("with a missing local exercise", function () { + let missingExercisePath: string; + + setup(async function () { + missingExercisePath = path.join(projectsDir, "missing-course", "missing-exercise"); + }); + + test("should encounter an error when attempting to clean it", async function () { + const result = await tmc.clean(missingExercisePath); + expect(result.val).to.be.instanceOf(RuntimeError); + }); + + test("should encounter an error when attempting to run tests for it", async function () { + const result = await tmc.runTests(missingExercisePath)[0]; + expect(result.val).to.be.instanceOf(RuntimeError); + }); + + // Downloads exercise on Langs 0.18 + test.skip("should encounter an error when attempting to revert to an older submission", async function () { + const result = await tmc.downloadOldSubmission(1, missingExercisePath, 0, false); + expect(result.val).to.be.instanceOf(RuntimeError); + }); + + test("should encounter an error when trying to reset it", async function () { + const result = await tmc.resetExercise(1, missingExercisePath, false); + expect(result.val).to.be.instanceOf(RuntimeError); + }); + + test("should encounter an error when trying to submit it", async function () { + const result = await tmc.submitExerciseAndWaitForResults(1, missingExercisePath); + expect(result.val).to.be.instanceOf(RuntimeError); + }); + + test("should encounter an error when trying to submit it to TMC-paste", async function () { + const result = await tmc.submitExerciseToPaste(404, missingExercisePath); + expect(result.val).to.be.instanceOf(RuntimeError); + }); + }); + }); + + suite("unauthenticated user", function () { + let onLoggedInCalls: number; + let onLoggedOutCalls: number; + let configDir: string; + let projectsDir: string; + let tmc: TMC; + + setup(function () { + configDir = path.join(testDir, CLIENT_CONFIG_DIR_NAME); + clearCredentials(configDir); + onLoggedInCalls = 0; + onLoggedOutCalls = 0; + projectsDir = setupProjectsDir(configDir, path.join(testDir, "tmcdata")); + tmc = new TMC(CLI_FILE, CLIENT_NAME, "test", { + cliConfigDir: testDir, + }); + tmc.on("login", () => onLoggedInCalls++); + tmc.on("logout", () => onLoggedOutCalls++); + }); + + // TODO: There was something fishy with this test + test("should not be able to authenticate with empty credentials", async function () { + const result = await tmc.authenticate("", ""); + expect(result.val).to.be.instanceOf(AuthenticationError); + }); + + test("should not be able to authenticate with incorrect credentials", async function () { + const result = await tmc.authenticate(USERNAME, "batman123"); + expect(result.val).to.be.instanceOf(AuthenticationError); + }); + + test("should be able to authenticate with correct credentials", async function () { + await unwrapResult(tmc.authenticate(USERNAME, PASSWORD)); + expect(onLoggedInCalls).to.be.equal(1); + + const result2 = await unwrapResult(tmc.isAuthenticated()); + expect(result2).to.be.true; + + expect(onLoggedOutCalls).to.be.equal(0); + }); + + test("should not be able to download an exercise", async function () { + const result = await tmc.downloadExercises([1], true, () => {}); + expect(result.val).to.be.instanceOf(RuntimeError); + }); + + test("should not get existing api data in general", async function () { + const dataResult = await tmc.getCourseData(0); + expect(dataResult.val).to.be.instanceOf(RuntimeError); + + const detailsResult = await tmc.getCourseDetails(0); + expect(detailsResult.val).to.be.instanceOf(AuthorizationError); + + const exercisesResult = await tmc.getCourseExercises(0); + expect(exercisesResult.val).to.be.instanceOf(AuthorizationError); + + const settingsResult = await tmc.getCourseSettings(0); + expect(settingsResult.val).to.be.instanceOf(AuthorizationError); + + const coursesResult = await tmc.getCourses("test"); + expect(coursesResult.val).to.be.instanceOf(AuthorizationError); + + const exerciseResult = await tmc.getExerciseDetails(1); + expect(exerciseResult.val).to.be.instanceOf(AuthorizationError); + + const submissionsResult = await tmc.getOldSubmissions(1); + expect(submissionsResult.val).to.be.instanceOf(AuthorizationError); + }); + + test("should be able to get valid organization data", async function () { + const organization = await unwrapResult(tmc.getOrganization("test")); + expect(organization.slug).to.be.equal("test"); + expect(organization.name).to.be.equal("Test Organization"); + + const organizations = await unwrapResult(tmc.getOrganizations()); + expect(organizations.length).to.be.equal(1, "Expected to get one organization."); + }); + + test("should encounter error if trying to get non-existing organization data", async function () { + const result = await tmc.getOrganization("404"); + expect(result.val).to.be.instanceOf(RuntimeError); + }); + + // This seems to ok? + test("should not be able to give feedback", async function () { + const feedback: SubmissionFeedback = { + status: [{ question_id: 0, answer: "42" }], + }; + const result = await tmc.submitSubmissionFeedback(FEEDBACK_URL, feedback); + expect(result.val).to.be.instanceOf(AuthorizationError); + }); + + suite("with a local exercise", function () { + this.timeout(20000); + + let exercisePath: string; + + setup(async function () { + delSync(projectsDir, { force: true }); + writeCredentials(configDir); + const result = (await tmc.downloadExercises([1], true, () => {})).unwrap(); + clearCredentials(configDir); + exercisePath = result.downloaded[0].path; + }); + + test("should be able to clean the exercise", async function () { + const result = await unwrapResult(tmc.clean(exercisePath)); + expect(result).to.be.undefined; + }); + + test("should be able to list local exercises", async function () { + const result = await unwrapResult(tmc.listLocalCourseExercises("python-course")); + expect(result.length).to.be.equal(1); + expect(first(result)?.["exercise-path"]).to.be.equal(exercisePath); + }); + + test("should be able to run tests for exercise", async function () { + const result = await unwrapResult(tmc.runTests(exercisePath)[0]); + expect(result.status).to.be.equal("PASSED"); + }); + + test("should not be able to load old submission", async function () { + const result = await tmc.downloadOldSubmission(1, exercisePath, 0, true); + expect(result.val).to.be.instanceOf(RuntimeError); + }); + + test("should not be able to reset exercise", async function () { + const result = await tmc.resetExercise(1, exercisePath, true); + expect(result.val).to.be.instanceOf(AuthorizationError); + }); + + test("should not be able to submit exercise", async function () { + const result = await tmc.submitExerciseAndWaitForResults(1, exercisePath); + expect(result.val).to.be.instanceOf(AuthorizationError); + }); + + // This actually works + test.skip("should not be able to submit exercise to TMC-paste", async function () { + const result = await tmc.submitExerciseToPaste(1, exercisePath); + expect(result.val).to.be.instanceOf(AuthorizationError); + }); + }); + }); + + suiteTeardown(function () { + server && kill(server.pid as number); + }); +}); + +function writeCredentials(configDir: string): void { + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync( + path.join(configDir, "credentials.json"), + '{"access_token":"1234","token_type":"bearer","scope":"public"}', + ); +} + +function clearCredentials(configDir: string): void { + delSync(path.join(configDir, "credentials.json"), { force: true }); +} + +function setupProjectsDir(configDir: string, projectsDir: string): string { + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(path.join(configDir, "config.toml"), `projects-dir = '${projectsDir}'\n`); + return projectsDir; +} + +async function unwrapResult(result: Promise>): Promise { + const res = await result; + if (res.err) expect.fail(`TMC-langs execution failed: ${res.val.message}`); + return res.val; +} + +async function startServer(): Promise { + let ready = false; + console.log(path.join(__dirname, "..", "backend")); + const server = cp.spawn("npm", ["start"], { + cwd: path.join(__dirname, "..", "backend"), + shell: "bash", + }); + server.stdout.on("data", (chunk) => { + if (chunk.toString().startsWith("Server listening to")) { + ready = true; + } + }); + + const timeout = setTimeout(() => { + throw new Error("Failed to start server"); + }, 20000); + + while (!ready) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + clearTimeout(timeout); + return server; +} diff --git a/src/test/actions/checkForExerciseUpdates.test.ts b/src/test/actions/checkForExerciseUpdates.test.ts index bcfbf16d..4ced47a8 100644 --- a/src/test/actions/checkForExerciseUpdates.test.ts +++ b/src/test/actions/checkForExerciseUpdates.test.ts @@ -1,14 +1,14 @@ import { expect } from "chai"; import { Err, Ok } from "ts-results"; -import { IMock, It, Mock, Times } from "typemoq"; +import { IMock, It, Times } from "typemoq"; import { checkForExerciseUpdates } from "../../actions"; import { ActionContext } from "../../actions/types"; import TMC from "../../api/tmc"; import { UserData } from "../../config/userdata"; -import { v2_0_0 as userData } from "../fixtures/userData"; import { createMockActionContext } from "../mocks/actionContext"; import { createTMCMock, TMCMockValues } from "../mocks/tmc"; +import { createUserDataMock } from "../mocks/userdata"; suite("checkForExerciseUpdates action", function () { const stubContext = createMockActionContext(); @@ -26,8 +26,7 @@ suite("checkForExerciseUpdates action", function () { setup(function () { [tmcMock, tmcMockValues] = createTMCMock(); - userDataMock = Mock.ofType(); - userDataMock.setup((x) => x.getCourses()).returns(() => userData.courses); + [userDataMock] = createUserDataMock(); }); test("should return exercise updates", async function () { diff --git a/src/test/actions/downloadOrUpdateExercises.test.ts b/src/test/actions/downloadOrUpdateExercises.test.ts new file mode 100644 index 00000000..3f6b83f0 --- /dev/null +++ b/src/test/actions/downloadOrUpdateExercises.test.ts @@ -0,0 +1,276 @@ +import { expect } from "chai"; +import { first, last } from "lodash"; +import { Err, Ok, Result } from "ts-results"; +import { IMock, It, Times } from "typemoq"; + +import { downloadOrUpdateExercises } from "../../actions"; +import { ActionContext } from "../../actions/types"; +import Dialog from "../../api/dialog"; +import { DownloadOrUpdateCourseExercisesResult, ExerciseDownload } from "../../api/langsSchema"; +import TMC from "../../api/tmc"; +import Settings from "../../config/settings"; +import { ExerciseStatus, WebviewMessage } from "../../ui/types"; +import UI from "../../ui/ui"; +import TmcWebview from "../../ui/webview"; +import { createMockActionContext } from "../mocks/actionContext"; +import { createDialogMock } from "../mocks/dialog"; +import { createSettingsMock, SettingsMockValues } from "../mocks/settings"; +import { createTMCMock, TMCMockValues } from "../mocks/tmc"; +import { createUIMock, UIMockValues } from "../mocks/ui"; +import { createWebviewMock, WebviewMockValues } from "../mocks/webview"; + +const helloWorld: ExerciseDownload = { + "course-slug": "python-course", + "exercise-slug": "hello_world", + id: 1, + path: "/tmc/vscode/test-python-course/hello_world", +}; + +const otherWorld: ExerciseDownload = { + "course-slug": "python-course", + "exercise-slug": "other_world", + id: 2, + path: "/tmc/vscode/test-python-course/other_world", +}; + +suite("downloadOrUpdateExercises action", function () { + const stubContext = createMockActionContext(); + + let dialogMock: IMock; + let settingsMock: IMock; + let settingsMockValues: SettingsMockValues; + let tmcMock: IMock; + let tmcMockValues: TMCMockValues; + let uiMock: IMock; + let uiMockValues: UIMockValues; + let webviewMessages: WebviewMessage[]; + let webviewMock: IMock; + let webviewMockValues: WebviewMockValues; + + const actionContext = (): ActionContext => ({ + ...stubContext, + dialog: dialogMock.object, + settings: settingsMock.object, + tmc: tmcMock.object, + ui: uiMock.object, + }); + + const createDownloadResult = ( + downloaded: ExerciseDownload[], + skipped: ExerciseDownload[], + failed: Array<[ExerciseDownload, string[]]> | undefined, + ): Result => { + return Ok({ + downloaded, + failed, + skipped, + }); + }; + + setup(function () { + [dialogMock] = createDialogMock(); + [settingsMock, settingsMockValues] = createSettingsMock(); + [tmcMock, tmcMockValues] = createTMCMock(); + [uiMock, uiMockValues] = createUIMock(); + webviewMessages = []; + [webviewMock, webviewMockValues] = createWebviewMock(); + webviewMockValues.postMessage = (...x): number => webviewMessages.push(...x); + uiMockValues.webview = webviewMock.object; + }); + + test("should return empty results if no exercises are given", async function () { + const result = (await downloadOrUpdateExercises(actionContext(), [])).unwrap(); + expect(result.successful.length).to.be.equal(0); + expect(result.failed.length).to.be.equal(0); + }); + + test("should not call TMC-langs if no exercises are given", async function () { + await downloadOrUpdateExercises(actionContext(), []); + expect( + tmcMock.verify( + (x) => x.downloadExercises(It.isAny(), It.isAny(), It.isAny()), + Times.never(), + ), + ); + }); + + test("should return error if TMC-langs fails", async function () { + const error = new Error(); + tmcMockValues.downloadExercises = Err(error); + const result = await downloadOrUpdateExercises(actionContext(), [1, 2]); + expect(result.val).to.be.equal(error); + }); + + test("should return ids of successful downloads", async function () { + tmcMockValues.downloadExercises = createDownloadResult( + [helloWorld, otherWorld], + [], + undefined, + ); + const result = (await downloadOrUpdateExercises(actionContext(), [1, 2])).unwrap(); + expect(result.successful).to.be.deep.equal([1, 2]); + }); + + test("should return ids of skipped downloads as successful", async function () { + tmcMockValues.downloadExercises = createDownloadResult( + [], + [helloWorld, otherWorld], + undefined, + ); + const result = (await downloadOrUpdateExercises(actionContext(), [1, 2])).unwrap(); + expect(result.successful).to.be.deep.equal([1, 2]); + }); + + test("should combine successful and skipped downloads", async function () { + tmcMockValues.downloadExercises = createDownloadResult( + [helloWorld], + [otherWorld], + undefined, + ); + const result = (await downloadOrUpdateExercises(actionContext(), [1])).unwrap(); + expect(result.successful).to.be.deep.equal([1, 2]); + }); + + test("should return ids of failed downloads", async function () { + tmcMockValues.downloadExercises = createDownloadResult( + [], + [], + [ + [helloWorld, [""]], + [otherWorld, [""]], + ], + ); + const result = (await downloadOrUpdateExercises(actionContext(), [1, 2])).unwrap(); + expect(result.failed).to.be.deep.equal([1, 2]); + }); + + test("should download template if downloadOldSubmission setting is off", async function () { + tmcMockValues.downloadExercises = createDownloadResult([helloWorld], [], undefined); + settingsMockValues.getDownloadOldSubmission = false; + await downloadOrUpdateExercises(actionContext(), [1]); + tmcMock.verify( + (x) => x.downloadExercises(It.isAny(), It.isValue(true), It.isAny()), + Times.once(), + ); + tmcMock.verify( + (x) => x.downloadExercises(It.isAny(), It.isValue(false), It.isAny()), + Times.never(), + ); + }); + + test("should not necessarily download template if downloadOldSubmission setting is on", async function () { + tmcMockValues.downloadExercises = createDownloadResult([helloWorld], [], undefined); + settingsMockValues.getDownloadOldSubmission = true; + await downloadOrUpdateExercises(actionContext(), [1]); + tmcMock.verify( + (x) => x.downloadExercises(It.isAny(), It.isValue(true), It.isAny()), + Times.never(), + ); + tmcMock.verify( + (x) => x.downloadExercises(It.isAny(), It.isValue(false), It.isAny()), + Times.once(), + ); + }); + + test("should post status updates of succeeding download", async function () { + tmcMock.reset(); + tmcMock + .setup((x) => x.downloadExercises(It.isAny(), It.isAny(), It.isAny())) + .returns(async (_1, _2, cb) => { + // Callback is only used for successful downloads + cb({ id: helloWorld.id, percent: 0.5 }); + return createDownloadResult([helloWorld], [], undefined); + }); + await downloadOrUpdateExercises(actionContext(), [1]); + expect(webviewMessages.length).to.be.greaterThanOrEqual( + 2, + "expected at least two status messages", + ); + expect(first(webviewMessages)).to.be.deep.equal( + wrapToMessage(helloWorld.id, "downloading"), + 'expected first message to be "downloading"', + ); + expect(last(webviewMessages)).to.be.deep.equal( + wrapToMessage(helloWorld.id, "opened"), + 'expected last message to be "opened"', + ); + }); + + test("should post status updates for skipped download", async function () { + tmcMockValues.downloadExercises = createDownloadResult([], [helloWorld], undefined); + await downloadOrUpdateExercises(actionContext(), [1]); + expect(webviewMessages.length).to.be.greaterThanOrEqual( + 2, + "expected at least two status messages", + ); + expect(first(webviewMessages)).to.be.deep.equal( + wrapToMessage(helloWorld.id, "downloading"), + 'expected first message to be "downloading"', + ); + expect(last(webviewMessages)).to.be.deep.equal( + wrapToMessage(helloWorld.id, "closed"), + 'expected last message to be "closed"', + ); + }); + + test("should post status updates for failing download", async function () { + tmcMockValues.downloadExercises = createDownloadResult([], [], [[helloWorld, [""]]]); + await downloadOrUpdateExercises(actionContext(), [1]); + expect(webviewMessages.length).to.be.greaterThanOrEqual( + 2, + "expected at least two status messages", + ); + expect(first(webviewMessages)).to.be.deep.equal( + wrapToMessage(helloWorld.id, "downloading"), + 'expected first message to be "downloading"', + ); + expect(last(webviewMessages)).to.be.deep.equal( + wrapToMessage(helloWorld.id, "downloadFailed"), + 'expected last message to be "downloadFailed"', + ); + }); + + test("should post status updates for exercises missing from langs response", async function () { + tmcMockValues.downloadExercises = createDownloadResult([], [], undefined); + await downloadOrUpdateExercises(actionContext(), [1]); + expect(webviewMessages.length).to.be.greaterThanOrEqual( + 2, + "expected at least two status messages", + ); + expect(first(webviewMessages)).to.be.deep.equal( + wrapToMessage(helloWorld.id, "downloading"), + 'expected first message to be "downloading"', + ); + expect(last(webviewMessages)).to.be.deep.equal( + wrapToMessage(helloWorld.id, "downloadFailed"), + 'expected last message to be "downloadFailed"', + ); + }); + + test("should post status updates when TMC-langs operation fails", async function () { + const error = new Error(); + tmcMockValues.downloadExercises = Err(error); + await downloadOrUpdateExercises(actionContext(), [1]); + expect(webviewMessages.length).to.be.greaterThanOrEqual( + 2, + "expected at least two status messages", + ); + expect(first(webviewMessages)).to.be.deep.equal( + wrapToMessage(helloWorld.id, "downloading"), + 'expected first message to be "downloading"', + ); + expect(last(webviewMessages)).to.be.deep.equal( + wrapToMessage(helloWorld.id, "downloadFailed"), + 'expected last message to be "downloadFailed"', + ); + }); +}); + +// These kind of functions should maybe be in utils? +function wrapToMessage(exerciseId: number, status: ExerciseStatus): WebviewMessage { + return { + command: "exerciseStatusChange", + exerciseId, + status, + }; +} diff --git a/src/test/actions/moveExtensionDataPath.test.ts b/src/test/actions/moveExtensionDataPath.test.ts index a11d2e2c..3696215d 100644 --- a/src/test/actions/moveExtensionDataPath.test.ts +++ b/src/test/actions/moveExtensionDataPath.test.ts @@ -2,7 +2,7 @@ import { expect } from "chai"; import * as mockFs from "mock-fs"; import * as path from "path"; import { Err, Ok } from "ts-results"; -import { IMock, It, Mock, Times } from "typemoq"; +import { IMock, It, Times } from "typemoq"; import * as vscode from "vscode"; import { moveExtensionDataPath } from "../../actions"; @@ -10,10 +10,11 @@ import { ActionContext } from "../../actions/types"; import TMC from "../../api/tmc"; import WorkspaceManager, { ExerciseStatus } from "../../api/workspaceManager"; import { UserData } from "../../config/userdata"; -import { v2_0_0 as userData } from "../fixtures/userData"; import { workspaceExercises } from "../fixtures/workspaceManager"; import { createMockActionContext } from "../mocks/actionContext"; import { createTMCMock, TMCMockValues } from "../mocks/tmc"; +import { createUserDataMock } from "../mocks/userdata"; +import { createWorkspaceMangerMock, WorkspaceManagerMockValues } from "../mocks/workspaceManager"; suite("moveExtensionDataPath action", function () { const virtualFileSystem = { @@ -36,7 +37,7 @@ suite("moveExtensionDataPath action", function () { let tmcMockValues: TMCMockValues; let userDataMock: IMock; let workspaceManagerMock: IMock; - let workspaceManagerActiveCourse: string | undefined; + let workspaceManagerMockValues: WorkspaceManagerMockValues; const actionContext = (): ActionContext => ({ ...stubContext, @@ -48,20 +49,9 @@ suite("moveExtensionDataPath action", function () { setup(function () { mockFs(virtualFileSystem); [tmcMock, tmcMockValues] = createTMCMock(); - userDataMock = Mock.ofType(); - userDataMock.setup((x) => x.getCourses()).returns(() => userData.courses); - workspaceManagerMock = Mock.ofType(); - workspaceManagerMock - .setup((x) => x.activeCourse) - .returns(() => workspaceManagerActiveCourse); - workspaceManagerMock - .setup((x) => x.closeCourseExercises(It.isAny(), It.isAny())) - .returns(async () => Ok.EMPTY); - workspaceManagerMock - .setup((x) => x.getExercisesByCourseSlug(It.isValue(courseName))) - .returns(() => workspaceExercises); - workspaceManagerMock.setup((x) => x.setExercises(It.isAny())).returns(async () => Ok.EMPTY); - workspaceManagerActiveCourse = courseName; + [userDataMock] = createUserDataMock(); + [workspaceManagerMock, workspaceManagerMockValues] = createWorkspaceMangerMock(); + workspaceManagerMockValues.activeCourse = courseName; }); test("should change extension data path", async function () { @@ -98,7 +88,7 @@ suite("moveExtensionDataPath action", function () { }); test.skip("should not close anything if no course workspace is active", async function () { - workspaceManagerActiveCourse = undefined; + workspaceManagerMockValues.activeCourse = undefined; await moveExtensionDataPath(actionContext(), emptyFolder); workspaceManagerMock.verify( (x) => x.closeCourseExercises(It.isValue(courseName), It.isValue(openExerciseSlugs)), diff --git a/src/test/actions/refreshLocalExercises.test.ts b/src/test/actions/refreshLocalExercises.test.ts index 741b7239..c0934a36 100644 --- a/src/test/actions/refreshLocalExercises.test.ts +++ b/src/test/actions/refreshLocalExercises.test.ts @@ -1,15 +1,16 @@ import { expect } from "chai"; import { Err, Ok } from "ts-results"; -import { IMock, It, Mock, Times } from "typemoq"; +import { IMock, It, Times } from "typemoq"; import { refreshLocalExercises } from "../../actions/refreshLocalExercises"; import { ActionContext } from "../../actions/types"; import TMC from "../../api/tmc"; import WorkspaceManager from "../../api/workspaceManager"; import { UserData } from "../../config/userdata"; -import { v2_0_0 as userData } from "../fixtures/userData"; import { createMockActionContext } from "../mocks/actionContext"; import { createTMCMock, TMCMockValues } from "../mocks/tmc"; +import { createUserDataMock, UserDataMockValues } from "../mocks/userdata"; +import { createWorkspaceMangerMock, WorkspaceManagerMockValues } from "../mocks/workspaceManager"; suite("refreshLocalExercises action", function () { const stubContext = createMockActionContext(); @@ -17,7 +18,9 @@ suite("refreshLocalExercises action", function () { let tmcMock: IMock; let tmcMockValues: TMCMockValues; let userDataMock: IMock; + let userDataMockValues: UserDataMockValues; let workspaceManagerMock: IMock; + let workspaceManagerMockValues: WorkspaceManagerMockValues; const actionContext = (): ActionContext => ({ ...stubContext, @@ -28,10 +31,8 @@ suite("refreshLocalExercises action", function () { setup(function () { [tmcMock, tmcMockValues] = createTMCMock(); - userDataMock = Mock.ofType(); - userDataMock.setup((x) => x.getCourses()).returns(() => userData.courses); - workspaceManagerMock = Mock.ofType(); - workspaceManagerMock.setup((x) => x.setExercises(It.isAny())).returns(async () => Ok.EMPTY); + [userDataMock, userDataMockValues] = createUserDataMock(); + [workspaceManagerMock, workspaceManagerMockValues] = createWorkspaceMangerMock(); }); test("should set exercises to WorkspaceManager", async function () { @@ -41,8 +42,7 @@ suite("refreshLocalExercises action", function () { }); test("should work without any courses", async function () { - userDataMock.reset(); - userDataMock.setup((x) => x.getCourses()).returns(() => []); + userDataMockValues.getCourses = []; const result = await refreshLocalExercises(actionContext()); expect(result).to.be.equal(Ok.EMPTY); }); @@ -55,10 +55,7 @@ suite("refreshLocalExercises action", function () { }); test("should return error if WorkspaceManager operation fails", async function () { - workspaceManagerMock.reset(); - workspaceManagerMock - .setup((x) => x.setExercises(It.isAny())) - .returns(async () => Err(new Error())); + workspaceManagerMockValues.setExercises = Err(new Error()); const result = await refreshLocalExercises(actionContext()); expect(result.val).to.be.instanceOf(Error); }); diff --git a/src/test/api/exerciseDecorationProvider.test.ts b/src/test/api/exerciseDecorationProvider.test.ts new file mode 100644 index 00000000..b2f81338 --- /dev/null +++ b/src/test/api/exerciseDecorationProvider.test.ts @@ -0,0 +1,84 @@ +import { expect } from "chai"; +import { IMock, It, Times } from "typemoq"; +import * as vscode from "vscode"; + +import ExerciseDecorationProvider from "../../api/exerciseDecorationProvider"; +import WorkspaceManager from "../../api/workspaceManager"; +import { UserData } from "../../config/userdata"; +import { userDataExerciseHelloWorld } from "../fixtures/userData"; +import { exerciseHelloWorld } from "../fixtures/workspaceManager"; +import { createUserDataMock, UserDataMockValues } from "../mocks/userdata"; +import { createWorkspaceMangerMock, WorkspaceManagerMockValues } from "../mocks/workspaceManager"; + +suite("ExerciseDecoratorProvider class", function () { + let userDataMock: IMock; + let userDataMockValues: UserDataMockValues; + let workspaceManagerMock: IMock; + let workspaceManagerMockValues: WorkspaceManagerMockValues; + + let exerciseDecorationProvider: ExerciseDecorationProvider; + + setup(function () { + [userDataMock, userDataMockValues] = createUserDataMock(); + userDataMockValues.getExerciseByName = userDataExerciseHelloWorld; + + [workspaceManagerMock, workspaceManagerMockValues] = createWorkspaceMangerMock(); + workspaceManagerMockValues.getExerciseByPath = exerciseHelloWorld; + + exerciseDecorationProvider = new ExerciseDecorationProvider( + userDataMock.object, + workspaceManagerMock.object, + ); + }); + + test("should decorate passed exercise with a filled circle", function () { + userDataMockValues.getExerciseByName = { ...userDataExerciseHelloWorld, passed: true }; + const decoration = exerciseDecorationProvider.provideFileDecoration(exerciseHelloWorld.uri); + expect((decoration as vscode.FileDecoration).badge).to.be.equal("⬤"); + }); + + test("should decorate expired exercise with an X mark", function () { + const expiredExercise = { ...userDataExerciseHelloWorld, deadline: "1970-01-01" }; + userDataMockValues.getExerciseByName = expiredExercise; + const decoration = exerciseDecorationProvider.provideFileDecoration(exerciseHelloWorld.uri); + expect((decoration as vscode.FileDecoration).badge).to.be.equal("✗"); + }); + + test("should decorate partially completed exercise with small circle", function () { + const partialCompletion = { + ...userDataExerciseHelloWorld, + awardedPoints: 1, + passed: false, + }; + userDataMockValues.getExerciseByName = partialCompletion; + const decoration = exerciseDecorationProvider.provideFileDecoration(exerciseHelloWorld.uri); + expect((decoration as vscode.FileDecoration).badge).to.be.equal("○"); + }); + + test("should decorate exercise missing from UserData with information symbol", function () { + userDataMockValues.getExerciseByName = undefined; + const decoration = exerciseDecorationProvider.provideFileDecoration(exerciseHelloWorld.uri); + expect((decoration as vscode.FileDecoration).badge).to.be.equal("ⓘ"); + }); + + test("should not decorate valid exercise that isn't yet passed", function () { + userDataMockValues.getExerciseByName = { ...userDataExerciseHelloWorld, passed: false }; + const decoration = exerciseDecorationProvider.provideFileDecoration(exerciseHelloWorld.uri); + expect(decoration).to.be.undefined; + }); + + test("should not decorate exercise folder subitem", function () { + const rootUri = vscode.Uri.file("/tmc/vscode/test-python-course/hello_world"); + const subUri = vscode.Uri.file("/tmc/vscode/test-python-course/hello_world/src/hello.py"); + workspaceManagerMockValues.getExerciseByPath = { ...exerciseHelloWorld, uri: rootUri }; + const decoration = exerciseDecorationProvider.provideFileDecoration(subUri); + expect(decoration).to.be.undefined; + }); + + test("should not attempt to decorate a non-exercise", function () { + const notExercise = vscode.Uri.file("something.txt"); + const decoration = exerciseDecorationProvider.provideFileDecoration(notExercise); + expect(decoration).to.be.undefined; + userDataMock.verify((x) => x.getExerciseByName(It.isAny(), It.isAny()), Times.never()); + }); +}); diff --git a/src/test/api/storage.test.ts b/src/test/api/storage.test.ts index d4149394..6ec878ba 100644 --- a/src/test/api/storage.test.ts +++ b/src/test/api/storage.test.ts @@ -1,6 +1,7 @@ import { expect } from "chai"; -import Storage, { ExtensionSettings, SessionState, UserData } from "../../api/storage"; +import Storage, { ExtensionSettings, SessionState } from "../../api/storage"; +import { v2_1_0 as userData } from "../fixtures/userData"; import { createMockContext } from "../mocks/vscode"; suite("Storage class", function () { @@ -16,41 +17,6 @@ suite("Storage class", function () { extensionVersion: "2.0.0", }; - const userData: UserData = { - courses: [ - { - id: 0, - availablePoints: 3, - awardedPoints: 0, - description: "Python Course", - disabled: true, - exercises: [ - { - id: 1, - deadline: null, - name: "hello_world", - passed: false, - softDeadline: null, - }, - { - id: 2, - deadline: "20201214", - name: "other_hello_world", - passed: false, - softDeadline: "20201212", - }, - ], - materialUrl: "mooc.fi", - name: "test-python-course", - newExercises: [2, 3, 4], - notifyAfter: 1234, - organization: "test", - perhapsExamMode: true, - title: "The Python Course", - }, - ], - }; - let storage: Storage; setup(function () { diff --git a/src/test/api/tmc.test.ts b/src/test/api/tmc.test.ts deleted file mode 100644 index 9b1f6726..00000000 --- a/src/test/api/tmc.test.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { expect } from "chai"; -import { sync as delSync } from "del"; -import * as fs from "fs-extra"; -import * as path from "path"; - -import TMC from "../../api/tmc"; -import { SubmissionFeedback } from "../../api/types"; -import { CLIENT_NAME, TMC_LANGS_CONFIG_DIR, TMC_LANGS_VERSION } from "../../config/constants"; -import { - AuthenticationError, - AuthorizationError, - BottleneckError, - RuntimeError, -} from "../../errors"; -import { getLangsCLIForPlatform, getPlatform } from "../../utils/"; - -suite("TMC", function () { - // Use CLI from backend folder to run tests. The location is relative to the dist-folder - // where webpack builds the test bundle. - const BACKEND_FOLDER = path.join(__dirname, "..", "backend"); - const CLI_PATH = path.join(BACKEND_FOLDER, "cli"); - const CLI_FILE = path.join(CLI_PATH, getLangsCLIForPlatform(getPlatform(), TMC_LANGS_VERSION)); - const ARTIFACT_PATH = path.join(BACKEND_FOLDER, "testArtifacts"); - - const FEEDBACK_URL = "http://localhost:4001/feedback"; - const COURSE_PATH = path.join(BACKEND_FOLDER, "resources", "test-python-course"); - const PASSING_EXERCISE_PATH = path.join(COURSE_PATH, "part01-01_passing_exercise"); - const MISSING_EXERCISE_PATH = path.join(COURSE_PATH, "part01-404_missing_exercise"); - - function removeArtifacts(): void { - delSync(ARTIFACT_PATH, { force: true }); - } - - function removeCliConfig(): void { - const config = path.join(CLI_PATH, `tmc-${CLIENT_NAME}`); - delSync(config, { force: true }); - } - - function writeCliConfig(): void { - const configPath = path.join(CLI_PATH, `tmc-${CLIENT_NAME}`); - if (!fs.existsSync(configPath)) { - fs.mkdirSync(configPath, { recursive: true }); - } - - fs.writeFileSync( - path.join(configPath, "credentials.json"), - '{"access_token":"1234","token_type":"bearer","scope":"public"}', - ); - } - - let tmc: TMC; - - setup(function () { - removeCliConfig(); - tmc = new TMC(CLI_FILE, CLIENT_NAME, "test", { - cliConfigDir: TMC_LANGS_CONFIG_DIR, - }); - }); - - suite("#authenticate()", function () { - test.skip("Causes AuthenticationError with empty credentials", async function () { - const result = await tmc.authenticate("", ""); - expect(result.val).to.be.instanceOf(AuthenticationError); - }); - - test("Causes AuthenticationError with incorrect credentials", async function () { - const result = await tmc.authenticate("TestMyCode", "hunter2"); - expect(result.val).to.be.instanceOf(AuthenticationError); - }); - - test("Succeeds with correct credentials", async function () { - const result = await tmc.authenticate("TestMyExtension", "hunter2"); - expect(result.ok).to.be.true; - }); - - test("Causes AuthenticationError when already authenticated", async function () { - writeCliConfig(); - const result = await tmc.authenticate("TestMyExtension", "hunter2"); - expect(result.val).to.be.instanceOf(AuthenticationError); - }); - }); - - suite("#isAuthenticated()", function () { - test("Returns false when user config is missing", async function () { - const result = await tmc.isAuthenticated(); - expect(result.val).to.be.false; - }); - - test("Returns true when user config exists", async function () { - writeCliConfig(); - const result = await tmc.isAuthenticated(); - expect(result.val).to.be.true; - }); - }); - - suite("#deauthenticate()", function () { - test("Deauthenticates", async function () { - const result = await tmc.deauthenticate(); - expect(result.ok).to.be.true; - }); - }); - - suite("#clean()", function () { - test("Clears exercise", async function () { - const result = (await tmc.clean(PASSING_EXERCISE_PATH)).unwrap(); - expect(result).to.be.undefined; - }); - - test("Causes RuntimeError for nonexistent exercise", async function () { - const result = await tmc.clean(MISSING_EXERCISE_PATH); - expect(result.val).to.be.instanceOf(RuntimeError); - }); - }); - - suite("#runTests()", function () { - test("Returns test results", async function () { - const result = (await tmc.runTests(PASSING_EXERCISE_PATH)[0]).unwrap(); - expect(result.status).to.be.equal("PASSED"); - }).timeout(20000); - - test("Can be interrupted"); - - test("Causes RuntimeError for nonexistent exercise", async function () { - const result = await tmc.runTests(MISSING_EXERCISE_PATH)[0]; - expect(result.val).to.be.instanceOf(RuntimeError); - }); - }); - - suite("#downloadExercise()", function () { - this.timeout(5000); - const downloadPath = path.join(ARTIFACT_PATH, "downloadsExercise"); - - test("Downloads exercise", async function () { - const result = await tmc.downloadExercise(1, downloadPath); - expect(result.ok).to.be.true; - }); - - teardown(function () { - removeArtifacts(); - }); - }); - - suite("#downloadOldSubmission()", function () { - this.timeout(5000); - const downloadPath = path.join(ARTIFACT_PATH, "downloadsOldSubmission"); - - setup(async function () { - await tmc.downloadExercise(1, downloadPath); - }); - - test("Causes AuthorizationError if not authenticated", async function () { - const result = await tmc.downloadOldSubmission(1, downloadPath, 404, false); - expect(result.val).to.be.instanceOf(AuthorizationError); - }); - - test("Downloads old submission", async function () { - writeCliConfig(); - const submissionId = (await tmc.getOldSubmissions(1)).unwrap()[0].id; - const result = await tmc.downloadOldSubmission(1, downloadPath, submissionId, false); - expect(result.ok).to.be.true; - }); - - test("Doesn't save old state if not expected", async function () { - writeCliConfig(); - const submissions = (await tmc.getOldSubmissions(1)).unwrap(); - await tmc.downloadOldSubmission(1, downloadPath, submissions[0].id, false); - const newSubmissions = (await tmc.getOldSubmissions(1)).unwrap(); - expect(newSubmissions.length).to.be.equal(submissions.length); - }); - - test("Saves old state if expected", async function () { - writeCliConfig(); - const submissions = (await tmc.getOldSubmissions(1)).unwrap(); - await tmc.downloadOldSubmission(1, downloadPath, submissions[0].id, true); - const newSubmissions = (await tmc.getOldSubmissions(1)).unwrap(); - expect(newSubmissions.length).to.be.equal(submissions.length + 1); - }); - - test("Causes RuntimeError for nonexistent exercise", async function () { - writeCliConfig(); - const result = await tmc.downloadOldSubmission( - 1, - path.resolve(downloadPath, "..", "404"), - 1, - false, - ); - expect(result.val).to.be.instanceOf(RuntimeError); - }); - - teardown(function () { - removeArtifacts(); - }); - }); - - suite("#getCourseData()", function () { - test("Causes AuthorizationError if not authenticated", async function () { - const result = await tmc.getCourseData(0); - expect(result.val).to.be.instanceOf(AuthorizationError); - }); - - test("Returns course data when authenticated", async function () { - writeCliConfig(); - const data = (await tmc.getCourseData(0)).unwrap(); - expect(data.details.name).to.be.equal("python-course"); - expect(data.exercises.length).to.be.equal(2); - expect(data.settings.name).to.be.equal("python-course"); - }); - - test("Causes RuntimeError for nonexistent course", async function () { - writeCliConfig(); - const result = await tmc.getCourseData(404); - expect(result.val).to.be.instanceOf(RuntimeError); - }); - }); - - suite("#getCourseDetails()", function () { - test("Causes AuthorizationError if not authenticated", async function () { - const result = await tmc.getCourseDetails(0); - expect(result.val).to.be.instanceOf(AuthorizationError); - }); - - test("Returns course details of given course", async function () { - writeCliConfig(); - const course = (await tmc.getCourseDetails(0)).unwrap().course; - expect(course.id).to.be.equal(0); - expect(course.name).to.be.equal("python-course"); - }); - - test("Causes RuntimeError for nonexistent course", async function () { - writeCliConfig(); - const result = await tmc.getCourseDetails(404); - expect(result.val).to.be.instanceOf(RuntimeError); - }); - }); - - suite("#getCourseExercises()", function () { - test("Causes AuthorizationError if not authenticated", async function () { - const result = await tmc.getCourseExercises(0); - expect(result.val).to.be.instanceOf(AuthorizationError); - }); - - test("Returns course exercises of the given course", async function () { - writeCliConfig(); - const exercises = (await tmc.getCourseExercises(0)).unwrap(); - expect(exercises.length).to.be.equal(2); - }); - - test("Causes RuntimeError for nonexistent course", async function () { - writeCliConfig(); - const result = await tmc.getCourseExercises(404); - expect(result.val).to.be.instanceOf(RuntimeError); - }); - }); - - suite("#getCourses()", function () { - test("Causes AuthorizationError if not authenticated", async function () { - const result = await tmc.getCourses("test"); - expect(result.val).to.be.instanceOf(AuthorizationError); - }); - - test("Returns courses when authenticated", async function () { - writeCliConfig(); - const course = (await tmc.getCourses("test")).unwrap(); - expect(course.length).to.be.equal(1); - expect(course.some((x) => x.name === "python-course")).to.be.true; - }); - - test("Causes RuntimeError for nonexistent organization", async function () { - writeCliConfig(); - const result = await tmc.getCourses("404"); - expect(result.val).to.be.instanceOf(RuntimeError); - }); - }); - - suite("#getCourseSettings()", function () { - test("Causes AuthorizationError if not authenticated", async function () { - const result = await tmc.getCourseSettings(0); - expect(result.val).to.be.instanceOf(AuthorizationError); - }); - - test("Returns course settings when authenticated", async function () { - writeCliConfig(); - const course = (await tmc.getCourseSettings(0)).unwrap(); - expect(course.name).to.be.equal("python-course"); - }); - - test("Causes RuntimeError for nonexistent course", async function () { - writeCliConfig(); - const result = await tmc.getCourseSettings(404); - expect(result.val).to.be.instanceOf(RuntimeError); - }); - }); - - suite("#getExerciseDetails()", function () { - test("Causes AuthorizationError if not authenticated", async function () { - const result = await tmc.getExerciseDetails(1); - expect(result.val).to.be.instanceOf(AuthorizationError); - }); - - test("Returns exercise details when authenticated", async function () { - writeCliConfig(); - const exercise = (await tmc.getExerciseDetails(1)).unwrap(); - expect(exercise.exercise_name).to.be.equal("part01-01_passing_exercise"); - }); - - test("Causes RuntimeError for nonexistent exercise", async function () { - writeCliConfig(); - const result = await tmc.getExerciseDetails(404); - expect(result.val).to.be.instanceOf(RuntimeError); - }); - }); - - suite("#getOldSubmissions()", function () { - test("Causes AuthorizationError if not authenticated", async function () { - const result = await tmc.getOldSubmissions(1); - expect(result.val).to.be.instanceOf(AuthorizationError); - }); - - test("Returns old submissions when authenticated", async function () { - writeCliConfig(); - const submissions = (await tmc.getOldSubmissions(1)).unwrap(); - expect(submissions.length).to.be.greaterThan(0); - }); - - test("Causes RuntimeError for nonexistent exercise", async function () { - writeCliConfig(); - const result = await tmc.getOldSubmissions(404); - expect(result.val).to.be.instanceOf(RuntimeError); - }); - }); - - suite("#getOrganizations()", function () { - test("Returns organizations", async function () { - const result = await tmc.getOrganizations(); - expect(result.unwrap().length).to.be.equal(1, "Expected to get one organization."); - }); - }); - - suite("#getOrganization()", function () { - test("Returns given organization", async function () { - const organization = (await tmc.getOrganization("test")).unwrap(); - expect(organization.slug).to.be.equal("test"); - expect(organization.name).to.be.equal("Test Organization"); - }); - - test("Returns RuntimeError for nonexistent organization", async function () { - const result = await tmc.getOrganization("404"); - expect(result.val).to.be.instanceOf(RuntimeError); - }); - }); - - suite("#resetExercise()", function () { - this.timeout(5000); - const downloadPath = path.join(ARTIFACT_PATH, "downloadsOldSubmission"); - - setup(async function () { - await tmc.downloadExercise(1, downloadPath); - }); - - test("Downloads old submission", async function () { - writeCliConfig(); - const submissionId = (await tmc.getOldSubmissions(1)).unwrap()[0].id; - const result = await tmc.downloadOldSubmission(1, downloadPath, submissionId, false); - expect(result.ok).to.be.true; - }); - - test("Doesn't save old state if not expected", async function () { - writeCliConfig(); - const submissions = (await tmc.getOldSubmissions(1)).unwrap(); - await tmc.downloadOldSubmission(1, downloadPath, submissions[0].id, false); - const newSubmissions = (await tmc.getOldSubmissions(1)).unwrap(); - expect(newSubmissions.length).to.be.equal(submissions.length); - }); - - test("Saves old state if expected", async function () { - writeCliConfig(); - const submissions = (await tmc.getOldSubmissions(1)).unwrap(); - await tmc.downloadOldSubmission(1, downloadPath, submissions[0].id, true); - const newSubmissions = (await tmc.getOldSubmissions(1)).unwrap(); - expect(newSubmissions.length).to.be.equal(submissions.length + 1); - }); - - teardown(function () { - removeArtifacts(); - }); - }); - - suite("#submitExerciseAndWaitForResults()", function () { - test("Causes AuthorizationError if not authenticated", async function () { - const result = await tmc.submitExerciseAndWaitForResults(1, PASSING_EXERCISE_PATH); - expect(result.val).to.be.instanceOf(AuthorizationError); - }); - - test("Makes a submission and returns results when authenticated", async function () { - this.timeout(5000); - writeCliConfig(); - const results = ( - await tmc.submitExerciseAndWaitForResults(1, PASSING_EXERCISE_PATH) - ).unwrap(); - expect(results.status).to.be.equal("ok"); - }); - - test("Returns submission link during the submission process", async function () { - this.timeout(5000); - writeCliConfig(); - let url: string | undefined; - await tmc.submitExerciseAndWaitForResults( - 1, - PASSING_EXERCISE_PATH, - undefined, - (x) => (url = x), - ); - expect(url).to.be.ok; - }); - - test("should result in BottleneckError if called twice too soon", async function () { - this.timeout(5000); - const first = tmc.submitExerciseAndWaitForResults(1, PASSING_EXERCISE_PATH); - const second = tmc.submitExerciseAndWaitForResults(1, PASSING_EXERCISE_PATH); - const [, secondResult] = await Promise.all([first, second]); - expect(secondResult.val).to.be.instanceOf(BottleneckError); - }); - - test("Causes RuntimeError for nonexistent exercise", async function () { - writeCliConfig(); - const result = await tmc.submitExerciseAndWaitForResults(1, MISSING_EXERCISE_PATH); - expect(result.val).to.be.instanceOf(RuntimeError); - }); - }); - - suite("#submitExerciseToPaste()", function () { - // Current Langs doesn't actually check this - test.skip("Causes AuthorizationError if not authenticated", async function () { - const result = await tmc.submitExerciseToPaste(1, PASSING_EXERCISE_PATH); - expect(result.val).to.be.instanceOf(AuthorizationError); - }); - - test("Makes a paste submission when authenticated", async function () { - writeCliConfig(); - const pasteUrl = (await tmc.submitExerciseToPaste(1, PASSING_EXERCISE_PATH)).unwrap(); - expect(pasteUrl).to.include("localhost"); - }); - - test("should result in BottleneckError if called twice too soon", async function () { - this.timeout(5000); - const first = tmc.submitExerciseAndWaitForResults(1, PASSING_EXERCISE_PATH); - const second = tmc.submitExerciseAndWaitForResults(1, PASSING_EXERCISE_PATH); - const [, secondResult] = await Promise.all([first, second]); - expect(secondResult.val).to.be.instanceOf(BottleneckError); - }); - - test("Causes RuntimeError for nonexistent exercise", async function () { - writeCliConfig(); - const result = await tmc.submitExerciseToPaste(404, MISSING_EXERCISE_PATH); - expect(result.val).to.be.instanceOf(RuntimeError); - }); - }); - - suite("$submitSubmissionFeedback()", function () { - const feedback: SubmissionFeedback = { - status: [{ question_id: 0, answer: "42" }], - }; - - test("Submits feedback when authenticated", async function () { - const result = await tmc.submitSubmissionFeedback(FEEDBACK_URL, feedback); - expect(result.ok).to.be.true; - }); - }); - - suiteTeardown(removeCliConfig); -}); diff --git a/src/test/commands/cleanExercise.test.ts b/src/test/commands/cleanExercise.test.ts index 968f9bce..4d173081 100644 --- a/src/test/commands/cleanExercise.test.ts +++ b/src/test/commands/cleanExercise.test.ts @@ -1,5 +1,5 @@ import * as path from "path"; -import { IMock, It, Mock, Times } from "typemoq"; +import { IMock, It, Times } from "typemoq"; import * as vscode from "vscode"; import { ActionContext } from "../../actions/types"; @@ -8,6 +8,7 @@ import WorkspaceManager, { ExerciseStatus } from "../../api/workspaceManager"; import { cleanExercise } from "../../commands"; import { createMockActionContext } from "../mocks/actionContext"; import { createTMCMock } from "../mocks/tmc"; +import { createWorkspaceMangerMock, WorkspaceManagerMockValues } from "../mocks/workspaceManager"; suite("Clean exercise command", function () { const BACKEND_FOLDER = path.join(__dirname, "..", "backend"); @@ -19,6 +20,7 @@ suite("Clean exercise command", function () { let tmcMock: IMock; let workspaceManagerMock: IMock; + let workspaceManagerMockValues: WorkspaceManagerMockValues; function actionContext(): ActionContext { return { @@ -30,37 +32,32 @@ suite("Clean exercise command", function () { setup(function () { [tmcMock] = createTMCMock(); - workspaceManagerMock = Mock.ofType(); + [workspaceManagerMock, workspaceManagerMockValues] = createWorkspaceMangerMock(); }); test("should clean active exercise by default", async function () { - workspaceManagerMock.setup((x) => x.uriIsExercise(It.isAny())).returns(() => true); - workspaceManagerMock - .setup((x) => x.activeExercise) - .returns(() => ({ - courseSlug: "test-python-course", - exerciseSlug: "part01-01_passing_exercise", - status: ExerciseStatus.Open, - uri, - })); + workspaceManagerMockValues.activeExercise = { + courseSlug: "test-python-course", + exerciseSlug: "part01-01_passing_exercise", + status: ExerciseStatus.Open, + uri, + }; await cleanExercise(actionContext(), undefined); tmcMock.verify((x) => x.clean(It.isValue(uri.fsPath)), Times.once()); }); test("should not clean active non-exercise", async function () { - workspaceManagerMock.setup((x) => x.activeExercise).returns(() => undefined); await cleanExercise(actionContext(), undefined); tmcMock.verify((x) => x.clean(It.isAny()), Times.never()); }); test("should clean provided exercise", async function () { - workspaceManagerMock.setup((x) => x.uriIsExercise(It.isAny())).returns(() => true); await cleanExercise(actionContext(), uri); tmcMock.verify((x) => x.clean(It.isValue(uri.fsPath)), Times.once()); }); test("should not clean provided non-exercise", async function () { - workspaceManagerMock.setup((x) => x.uriIsExercise(It.isAny())).returns(() => false); + workspaceManagerMockValues.uriIsExercise = false; await cleanExercise(actionContext(), uri); tmcMock.verify((x) => x.clean(It.isAny()), Times.never()); }); diff --git a/src/test/fixtures/userData.ts b/src/test/fixtures/userData.ts index c2816df6..cc16b56c 100644 --- a/src/test/fixtures/userData.ts +++ b/src/test/fixtures/userData.ts @@ -1,5 +1,20 @@ +import { LocalCourseExercise, UserData } from "../../api/storage"; import { LocalCourseDataV0, LocalCourseDataV1 } from "../../migrate/migrateUserData"; +export const userDataExerciseHelloWorld: LocalCourseExercise = { + id: 1, + availablePoints: 1, + awardedPoints: 0, + deadline: null, + name: "hello_world", + passed: false, + softDeadline: null, +}; + +// ------------------------------------------------------------------------------------------------- +// Previous version snapshots +// ------------------------------------------------------------------------------------------------- + interface UserDataV0 { courses: LocalCourseDataV0[]; } @@ -8,7 +23,7 @@ interface UserDataV1 { courses: LocalCourseDataV1[]; } -const v0_1_0: UserDataV0 = { +export const v0_1_0: UserDataV0 = { courses: [ { id: 0, @@ -23,7 +38,7 @@ const v0_1_0: UserDataV0 = { ], }; -const v0_2_0: UserDataV0 = { +export const v0_2_0: UserDataV0 = { courses: [ { id: 0, @@ -40,7 +55,7 @@ const v0_2_0: UserDataV0 = { ], }; -const v0_3_0: UserDataV0 = { +export const v0_3_0: UserDataV0 = { courses: [ { id: 0, @@ -59,7 +74,7 @@ const v0_3_0: UserDataV0 = { ], }; -const v0_4_0: UserDataV0 = { +export const v0_4_0: UserDataV0 = { courses: [ { id: 0, @@ -79,7 +94,7 @@ const v0_4_0: UserDataV0 = { ], }; -const v0_6_0: UserDataV0 = { +export const v0_6_0: UserDataV0 = { courses: [ { id: 0, @@ -99,7 +114,7 @@ const v0_6_0: UserDataV0 = { ], }; -const v0_8_0: UserDataV0 = { +export const v0_8_0: UserDataV0 = { courses: [ { id: 0, @@ -120,7 +135,7 @@ const v0_8_0: UserDataV0 = { ], }; -const v0_9_0: UserDataV0 = { +export const v0_9_0: UserDataV0 = { courses: [ { id: 0, @@ -143,7 +158,7 @@ const v0_9_0: UserDataV0 = { ], }; -const v1_0_0: UserDataV0 = { +export const v1_0_0: UserDataV0 = { courses: [ { id: 0, @@ -178,7 +193,7 @@ const v1_0_0: UserDataV0 = { ], }; -const v2_0_0: UserDataV1 = { +export const v2_0_0: UserDataV1 = { courses: [ { id: 0, @@ -213,4 +228,41 @@ const v2_0_0: UserDataV1 = { ], }; -export { v0_1_0, v0_2_0, v0_3_0, v0_4_0, v0_6_0, v0_8_0, v0_9_0, v1_0_0, v2_0_0 }; +export const v2_1_0: UserData = { + courses: [ + { + id: 0, + availablePoints: 3, + awardedPoints: 0, + description: "Python Course", + disabled: true, + exercises: [ + { + id: 1, + availablePoints: 1, + awardedPoints: 0, + deadline: null, + name: "hello_world", + passed: false, + softDeadline: null, + }, + { + id: 2, + availablePoints: 1, + awardedPoints: 0, + deadline: "20201214", + name: "other_world", + passed: false, + softDeadline: "20201212", + }, + ], + materialUrl: "mooc.fi", + name: "test-python-course", + newExercises: [2, 3, 4], + notifyAfter: 1234, + organization: "test", + perhapsExamMode: true, + title: "The Python Course", + }, + ], +}; diff --git a/src/test/fixtures/workspaceManager.ts b/src/test/fixtures/workspaceManager.ts index e0979120..5655abdb 100644 --- a/src/test/fixtures/workspaceManager.ts +++ b/src/test/fixtures/workspaceManager.ts @@ -2,19 +2,18 @@ import * as vscode from "vscode"; import { ExerciseStatus, WorkspaceExercise } from "../../api/workspaceManager"; -const workspaceExercises: WorkspaceExercise[] = [ - { - courseSlug: "test-python-course", - exerciseSlug: "hello_world", - status: ExerciseStatus.Open, - uri: vscode.Uri.file("/tmc/vscode/test-python-course/hello_world"), - }, - { - courseSlug: "test-python-course", - exerciseSlug: "other_world", - status: ExerciseStatus.Closed, - uri: vscode.Uri.file("/tmc/vscode/test-python-course/other_world"), - }, -]; +export const exerciseHelloWorld: WorkspaceExercise = { + courseSlug: "test-python-course", + exerciseSlug: "hello_world", + status: ExerciseStatus.Open, + uri: vscode.Uri.file("/tmc/vscode/test-python-course/hello_world"), +}; -export { workspaceExercises }; +export const exerciseOtherWorld: WorkspaceExercise = { + courseSlug: "test-python-course", + exerciseSlug: "other_world", + status: ExerciseStatus.Closed, + uri: vscode.Uri.file("/tmc/vscode/test-python-course/other_world"), +}; + +export const workspaceExercises: WorkspaceExercise[] = [exerciseHelloWorld, exerciseOtherWorld]; diff --git a/src/test/migrate/migrate.test.ts b/src/test/migrate/migrate.test.ts index e24ffcd7..48944d89 100644 --- a/src/test/migrate/migrate.test.ts +++ b/src/test/migrate/migrate.test.ts @@ -14,7 +14,7 @@ import * as sessionState from "../fixtures/sessionState"; import * as userData from "../fixtures/userData"; import { createDialogMock } from "../mocks/dialog"; import { createFailingTMCMock, createTMCMock } from "../mocks/tmc"; -import { createMockContext } from "../mocks/vscode"; +import { createMockContext, createMockWorkspaceConfiguration } from "../mocks/vscode"; const UNSTABLE_EXERCISE_DATA_KEY = "exerciseData"; const UNSTABLE_EXTENSION_SETTINGS_KEY = "extensionSettings"; @@ -36,11 +36,13 @@ suite("Extension data migration", function () { let dialogMock: IMock; let storage: Storage; let tmcMock: IMock; + let settingsMock: IMock; setup(function () { mockFs(virtualFileSystem); context = createMockContext(); [dialogMock] = createDialogMock(); + settingsMock = createMockWorkspaceConfiguration(); storage = new Storage(context); [tmcMock] = createTMCMock(); }); @@ -51,6 +53,7 @@ suite("Extension data migration", function () { storage, dialogMock.object, tmcMock.object, + settingsMock.object, ); expect(result.ok).to.be.true; }); @@ -66,6 +69,7 @@ suite("Extension data migration", function () { storage, dialogMock.object, tmcMock.object, + settingsMock.object, ); expect(result).to.be.equal(Ok.EMPTY); expect(storage.getUserData()).to.not.be.undefined; @@ -81,6 +85,7 @@ suite("Extension data migration", function () { storage, dialogMock.object, tmcMock.object, + settingsMock.object, ); expect(result.val).to.be.instanceOf(Error); expect(storage.getUserData()).to.be.undefined; @@ -100,6 +105,7 @@ suite("Extension data migration", function () { storage, dialogMock.object, tmcMock.object, + settingsMock.object, ); expect(result).to.be.equal(Ok.EMPTY); expect(storage.getUserData()).to.not.be.undefined; @@ -115,6 +121,7 @@ suite("Extension data migration", function () { storage, dialogMock.object, tmcMock.object, + settingsMock.object, ); expect(result.val).to.be.instanceOf(Error); expect(storage.getUserData()).to.be.undefined; @@ -138,6 +145,7 @@ suite("Extension data migration", function () { storage, dialogMock.object, tmcMock.object, + settingsMock.object, ); expect(result).to.be.equal(Ok.EMPTY); expect(storage.getUserData()).to.not.be.undefined; @@ -160,6 +168,7 @@ suite("Extension data migration", function () { storage, dialogMock.object, tmcMock.object, + settingsMock.object, ); expect(result.val).to.be.instanceOf(Error); expect(storage.getUserData()).to.be.undefined; @@ -187,6 +196,7 @@ suite("Extension data migration", function () { storage, dialogMock.object, tmcMock.object, + settingsMock.object, ); expect(result).to.be.equal(Ok.EMPTY); expect(storage.getUserData()).to.not.be.undefined; @@ -209,6 +219,7 @@ suite("Extension data migration", function () { storage, dialogMock.object, tmcMock.object, + settingsMock.object, ); expect(result.val).to.be.instanceOf(Error); expect(storage.getUserData()).to.be.undefined; @@ -233,6 +244,7 @@ suite("Extension data migration", function () { storage, dialogMock.object, tmcMock.object, + settingsMock.object, ); expect(result).to.be.equal(Ok.EMPTY); expect(storage.getUserData()).to.not.be.undefined; diff --git a/src/test/migrate/migrateExerciseData.test.ts b/src/test/migrate/migrateExerciseData.test.ts index 2b8e42d2..86bb00e4 100644 --- a/src/test/migrate/migrateExerciseData.test.ts +++ b/src/test/migrate/migrateExerciseData.test.ts @@ -87,7 +87,7 @@ suite("Exercise data migration", function () { await memento.update(UNSTABLE_EXTENSION_SETTINGS_KEY, { dataPath: "/tmcdata" }); await memento.update(EXERCISE_DATA_KEY_V0, exerciseData.v0_3_0); await migrateExerciseData(memento, dialogMock.object, tmcMock.object); - const testValue = JSON.stringify(["other_world"]); + const testValue = ["other_world"]; tmcMock.verify( (x) => x.setSetting( diff --git a/src/test/migrate/migrateExtensionSettings.test.ts b/src/test/migrate/migrateExtensionSettings.test.ts index 7534571d..552f34d9 100644 --- a/src/test/migrate/migrateExtensionSettings.test.ts +++ b/src/test/migrate/migrateExtensionSettings.test.ts @@ -1,5 +1,6 @@ import { expect, use } from "chai"; import * as chaiAsPromised from "chai-as-promised"; +import { IMock, It, Times } from "typemoq"; import * as vscode from "vscode"; import migrateExtensionSettings, { @@ -8,61 +9,159 @@ import migrateExtensionSettings, { } from "../../migrate/migrateExtensionSettings"; import { LogLevel } from "../../utils"; import * as extensionSettings from "../fixtures/extensionSettings"; -import { createMockMemento } from "../mocks/vscode"; +import { createMockMemento, createMockWorkspaceConfiguration } from "../mocks/vscode"; use(chaiAsPromised); const EXTENSION_SETTINGS_KEY_V0 = "extensionSettings"; const EXTENSION_SETTINGS_KEY_V1 = "extension-settings-v1"; +const UNSTABLE_EXTENSION_VERSION_KEY = "extensionVersion"; +const SESSION_STATE_KEY_V1 = "session-state-v1"; suite("Extension settings migration", function () { let memento: vscode.Memento; + let settingsMock: IMock; setup(function () { memento = createMockMemento(); + settingsMock = createMockWorkspaceConfiguration(); + }); + + suite("to vscode settings API", function () { + test("should not happen when no data", async function () { + await migrateExtensionSettings(memento, settingsMock.object); + settingsMock.verify((x) => x.update(It.isAny(), It.isAny(), It.isAny()), Times.never()); + }); + + test("should happen when no version is defined", async function () { + await memento.update(EXTENSION_SETTINGS_KEY_V0, extensionSettings.v0_5_0); + await migrateExtensionSettings(memento, settingsMock.object); + settingsMock.verify( + (x) => x.update(It.isAny(), It.isAny(), It.isAny()), + Times.atLeastOnce(), + ); + }); + + test("should happen when old version is lower than 1.1.0", async function () { + await memento.update(EXTENSION_SETTINGS_KEY_V0, extensionSettings.v0_5_0); + await memento.update(UNSTABLE_EXTENSION_VERSION_KEY, "0.1.0"); + await migrateExtensionSettings(memento, settingsMock.object); + settingsMock.verify( + (x) => x.update(It.isAny(), It.isAny(), It.isAny()), + Times.atLeastOnce(), + ); + }); + + test("should happen when old version is lower than 2.1.0", async function () { + await memento.update(EXTENSION_SETTINGS_KEY_V1, extensionSettings.v2_0_0); + await memento.update(SESSION_STATE_KEY_V1, { extensionVersion: "2.0.2" }); + await migrateExtensionSettings(memento, settingsMock.object); + settingsMock.verify( + (x) => x.update(It.isAny(), It.isAny(), It.isAny()), + Times.atLeastOnce(), + ); + }); + + test("should set correct values", async function () { + await memento.update(EXTENSION_SETTINGS_KEY_V1, extensionSettings.v2_0_0); + await memento.update(SESSION_STATE_KEY_V1, { extensionVersion: "2.0.2" }); + await migrateExtensionSettings(memento, settingsMock.object); + settingsMock.verify( + (x) => + x.update( + It.isValue("testMyCode.insiderVersion"), + It.isValue(extensionSettings.v2_0_0.insiderVersion), + It.isAny(), + ), + Times.once(), + ); + settingsMock.verify( + (x) => + x.update( + It.isValue("testMyCode.downloadOldSubmission"), + It.isValue(extensionSettings.v2_0_0.downloadOldSubmission), + It.isAny(), + ), + Times.once(), + ); + settingsMock.verify( + (x) => + x.update( + It.isValue("testMyCode.hideMetaFiles"), + It.isValue(extensionSettings.v2_0_0.hideMetaFiles), + It.isAny(), + ), + Times.once(), + ); + settingsMock.verify( + (x) => + x.update( + It.isValue("testMyCode.logLevel"), + It.isValue(extensionSettings.v2_0_0.logLevel), + It.isAny(), + ), + Times.once(), + ); + settingsMock.verify( + (x) => + x.update( + It.isValue("testMyCode.updateExercisesAutomatically"), + It.isValue(extensionSettings.v2_0_0.updateExercisesAutomatically), + It.isAny(), + ), + Times.once(), + ); + }); + + test("should not happen when version matches or is above 2.1.0", async function () { + await memento.update(EXTENSION_SETTINGS_KEY_V1, extensionSettings.v2_0_0); + await memento.update(SESSION_STATE_KEY_V1, { extensionVersion: "2.2.2" }); + await migrateExtensionSettings(memento, settingsMock.object); + settingsMock.verify((x) => x.update(It.isAny(), It.isAny(), It.isAny()), Times.never()); + }); }); suite("between versions", function () { test("should succeed without any data", async function () { - const migrated = (await migrateExtensionSettings(memento)).data; + const migrated = (await migrateExtensionSettings(memento, settingsMock.object)).data; expect(migrated).to.be.undefined; }); test("should succeed with version 0.5.0 data", async function () { await memento.update(EXTENSION_SETTINGS_KEY_V0, extensionSettings.v0_5_0); - const migrated = (await migrateExtensionSettings(memento)).data; + const migrated = (await migrateExtensionSettings(memento, settingsMock.object)).data; expect(migrated?.logLevel).to.be.equal("verbose"); expect(migrated?.hideMetaFiles).to.be.true; }); test("should succeed with version 0.9.0 data", async function () { await memento.update(EXTENSION_SETTINGS_KEY_V0, extensionSettings.v0_9_0); - const migrated = (await migrateExtensionSettings(memento)).data; + const migrated = (await migrateExtensionSettings(memento, settingsMock.object)).data; expect(migrated?.insiderVersion).to.be.true; }); test("should succeed with version 1.0.0 data", async function () { await memento.update(EXTENSION_SETTINGS_KEY_V0, extensionSettings.v1_0_0); - const migrated = (await migrateExtensionSettings(memento)).data; + const migrated = (await migrateExtensionSettings(memento, settingsMock.object)).data; expect(migrated?.downloadOldSubmission).to.be.false; }); test("should succeed with version 1.2.0 data", async function () { await memento.update(EXTENSION_SETTINGS_KEY_V0, extensionSettings.v1_2_0); - const migrated = (await migrateExtensionSettings(memento)).data; + const migrated = (await migrateExtensionSettings(memento, settingsMock.object)).data; expect(migrated?.updateExercisesAutomatically).to.be.false; }); test("should succeed with version 2.0.0 data", async function () { await memento.update(EXTENSION_SETTINGS_KEY_V1, extensionSettings.v2_0_0); - const migrated = (await migrateExtensionSettings(memento)).data; + const migrated = (await migrateExtensionSettings(memento, settingsMock.object)).data; expect(migrated).to.be.deep.equal(extensionSettings.v2_0_0); }); test("should succeed with backwards compatible future data", async function () { const data = { ...extensionSettings.v2_0_0, superman: "Clark Kent" }; await memento.update(EXTENSION_SETTINGS_KEY_V1, data); - const migrated = (await migrateExtensionSettings(memento)).data; + const migrated = (await migrateExtensionSettings(memento, settingsMock.object)).data; expect(migrated).to.be.deep.equal(data); }); }); @@ -72,12 +171,14 @@ suite("Extension settings migration", function () { test("should fail if data is garbage", async function () { await memento.update(EXTENSION_SETTINGS_KEY_V0, { superman: "Clark Kent" }); - expect(migrateExtensionSettings(memento)).to.be.rejectedWith(/missmatch/); + expect(migrateExtensionSettings(memento, settingsMock.object)).to.be.rejectedWith( + /missmatch/, + ); }); test("should set valid placeholders with minimal data", async function () { await memento.update(EXTENSION_SETTINGS_KEY_V0, { dataPath }); - const migrated = (await migrateExtensionSettings(memento)).data; + const migrated = (await migrateExtensionSettings(memento, settingsMock.object)).data; expect(migrated?.downloadOldSubmission).to.be.true; expect(migrated?.hideMetaFiles).to.be.true; expect(migrated?.insiderVersion).to.be.false; @@ -94,7 +195,8 @@ suite("Extension settings migration", function () { ]; for (const [oldLevel, expectedLevel] of expectedRemappings) { await memento.update(EXTENSION_SETTINGS_KEY_V0, { dataPath, logLevel: oldLevel }); - const migrated = (await migrateExtensionSettings(memento)).data; + const migrated = (await migrateExtensionSettings(memento, settingsMock.object)) + .data; expect(migrated?.logLevel).to.be.equal(expectedLevel); } }); @@ -103,7 +205,9 @@ suite("Extension settings migration", function () { suite("with stable data", function () { test("should fail with garbage version 1 data", async function () { await memento.update(EXTENSION_SETTINGS_KEY_V1, { superman: "Clark Kent" }); - expect(migrateExtensionSettings(memento)).to.be.rejectedWith(/missmatch/); + expect(migrateExtensionSettings(memento, settingsMock.object)).to.be.rejectedWith( + /missmatch/, + ); }); }); }); diff --git a/src/test/migrate/migrateUserData.test.ts b/src/test/migrate/migrateUserData.test.ts index 561ba959..4b0076c6 100644 --- a/src/test/migrate/migrateUserData.test.ts +++ b/src/test/migrate/migrateUserData.test.ts @@ -1,6 +1,11 @@ import { expect } from "chai"; import * as vscode from "vscode"; +import { + LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, + LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER, + LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER, +} from "../../config/constants"; import migrateUserData, { LocalCourseDataV0 } from "../../migrate/migrateUserData"; import * as exerciseData from "../fixtures/exerciseData"; import * as userData from "../fixtures/userData"; @@ -86,11 +91,32 @@ suite("User data migration", function () { test("should succeed with version 2.0.0 data", async function () { await memento.update(USER_DATA_KEY_V1, userData.v2_0_0); - expect(migrateUserData(memento).data).to.be.deep.equal(userData.v2_0_0); + const courses = migrateUserData(memento).data?.courses; + courses?.forEach((course) => { + course.exercises.forEach((x) => { + expect(x.availablePoints).to.be.equal( + LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, + ); + if (x.passed) { + expect(x.awardedPoints).to.be.equal( + LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER, + ); + } else { + expect(x.awardedPoints).to.be.equal( + LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER, + ); + } + }); + }); + }); + + test("should succeed with version 2.1.0 data", async function () { + await memento.update(USER_DATA_KEY_V1, userData.v2_1_0); + expect(migrateUserData(memento).data).to.be.deep.equal(userData.v2_1_0); }); test("should succeed with backwards compatible future data", async function () { - const data = { ...userData.v2_0_0, batman: "Bruce Wayne" }; + const data = { ...userData.v2_1_0, batman: "Bruce Wayne" }; await memento.update(USER_DATA_KEY_V1, data); expect(migrateUserData(memento).data).to.be.deep.equal(data); }); diff --git a/src/test/mocks/actionContext.ts b/src/test/mocks/actionContext.ts index 571ecd93..328920df 100644 --- a/src/test/mocks/actionContext.ts +++ b/src/test/mocks/actionContext.ts @@ -2,6 +2,7 @@ import { Mock } from "typemoq"; import { ActionContext } from "../../actions/types"; import Dialog from "../../api/dialog"; +import ExerciseDecorationProvider from "../../api/exerciseDecorationProvider"; import TMC from "../../api/tmc"; import WorkspaceManager from "../../api/workspaceManager"; import Resouces from "../../config/resources"; @@ -14,6 +15,7 @@ import UI from "../../ui/ui"; export function createMockActionContext(): ActionContext { return { dialog: Mock.ofType().object, + exerciseDecorationProvider: Mock.ofType().object, resources: Mock.ofType().object, settings: Mock.ofType().object, temporaryWebviewProvider: Mock.ofType().object, diff --git a/src/test/mocks/settings.ts b/src/test/mocks/settings.ts new file mode 100644 index 00000000..961f6e88 --- /dev/null +++ b/src/test/mocks/settings.ts @@ -0,0 +1,23 @@ +import { IMock, Mock } from "typemoq"; + +import Settings from "../../config/settings"; + +export interface SettingsMockValues { + getDownloadOldSubmission: boolean; +} + +export function createSettingsMock(): [IMock, SettingsMockValues] { + const values: SettingsMockValues = { + getDownloadOldSubmission: false, + }; + const mock = setupMockValues(values); + return [mock, values]; +} + +function setupMockValues(values: SettingsMockValues): IMock { + const mock = Mock.ofType(); + + mock.setup((x) => x.getDownloadOldSubmission()).returns(() => values.getDownloadOldSubmission); + + return mock; +} diff --git a/src/test/mocks/tmc.ts b/src/test/mocks/tmc.ts index 62f1c763..3af8f893 100644 --- a/src/test/mocks/tmc.ts +++ b/src/test/mocks/tmc.ts @@ -1,7 +1,7 @@ import { Err, Ok, Result } from "ts-results"; import { IMock, It, Mock } from "typemoq"; -import { LocalExercise } from "../../api/langsSchema"; +import { DownloadOrUpdateCourseExercisesResult, LocalExercise } from "../../api/langsSchema"; import TMC from "../../api/tmc"; import { checkExerciseUpdates, @@ -9,8 +9,11 @@ import { listLocalCourseExercisesPythonCourse, } from "../fixtures/tmc"; +const NOT_MOCKED_ERROR = Err(new Error("Method was not mocked.")); + export interface TMCMockValues { clean: Result; + downloadExercises: Result; listLocalCourseExercisesPythonCourse: Result; getSettingClosedExercises: Result; getSettingProjectsDir: Result; @@ -23,6 +26,7 @@ export interface TMCMockValues { export function createTMCMock(): [IMock, TMCMockValues] { const values: TMCMockValues = { clean: Ok.EMPTY, + downloadExercises: NOT_MOCKED_ERROR, listLocalCourseExercisesPythonCourse: Ok(listLocalCourseExercisesPythonCourse), getSettingClosedExercises: Ok(closedExercisesPythonCourse), getSettingProjectsDir: Ok("/langs/path/to/exercises"), @@ -40,6 +44,7 @@ export function createFailingTMCMock(): [IMock, TMCMockValues] { const error = Err(new Error()); const values: TMCMockValues = { clean: error, + downloadExercises: NOT_MOCKED_ERROR, listLocalCourseExercisesPythonCourse: error, getSettingClosedExercises: error, getSettingProjectsDir: error, @@ -98,5 +103,9 @@ function setupMockValues(values: TMCMockValues): IMock { async () => values.checkExerciseUpdates, ); + mock.setup((x) => x.downloadExercises(It.isAny(), It.isAny(), It.isAny())).returns( + async () => values.downloadExercises, + ); + return mock; } diff --git a/src/test/mocks/ui.ts b/src/test/mocks/ui.ts new file mode 100644 index 00000000..3c0d2129 --- /dev/null +++ b/src/test/mocks/ui.ts @@ -0,0 +1,26 @@ +import { IMock, Mock } from "typemoq"; + +import UI from "../../ui/ui"; +import TmcWebview from "../../ui/webview"; + +import { createWebviewMock } from "./webview"; + +export interface UIMockValues { + webview: TmcWebview; +} + +export function createUIMock(): [IMock, UIMockValues] { + const values: UIMockValues = { + webview: createWebviewMock()[0].object, + }; + const mock = setupMockValues(values); + return [mock, values]; +} + +function setupMockValues(values: UIMockValues): IMock { + const mock = Mock.ofType(); + + mock.setup((x) => x.webview).returns(() => values.webview); + + return mock; +} diff --git a/src/test/mocks/userdata.ts b/src/test/mocks/userdata.ts new file mode 100644 index 00000000..3a1a246f --- /dev/null +++ b/src/test/mocks/userdata.ts @@ -0,0 +1,32 @@ +import { IMock, It, Mock } from "typemoq"; + +import { LocalCourseData, LocalCourseExercise } from "../../api/storage"; +import { UserData } from "../../config/userdata"; +import { v2_1_0 as userData } from "../fixtures/userData"; + +export interface UserDataMockValues { + getCourses: LocalCourseData[]; + getExerciseByName: Readonly | undefined; +} + +export function createUserDataMock(): [IMock, UserDataMockValues] { + const values: UserDataMockValues = { + getCourses: userData.courses, + getExerciseByName: undefined, + }; + const mock = setupMockValues(values); + + return [mock, values]; +} + +function setupMockValues(values: UserDataMockValues): IMock { + const mock = Mock.ofType(); + + mock.setup((x) => x.getCourses()).returns(() => values.getCourses); + + mock.setup((x) => x.getExerciseByName(It.isAny(), It.isAny())).returns( + () => values.getExerciseByName, + ); + + return mock; +} diff --git a/src/test/mocks/vscode.ts b/src/test/mocks/vscode.ts index d63829dc..9605f6e8 100644 --- a/src/test/mocks/vscode.ts +++ b/src/test/mocks/vscode.ts @@ -1,4 +1,4 @@ -import { Mock } from "typemoq"; +import { IMock, It, Mock } from "typemoq"; import * as vscode from "vscode"; type Memento = vscode.Memento & { setKeysForSync(keys: string[]): void }; @@ -36,3 +36,13 @@ export function createMockMemento(): Memento { }); return mockMemento.object; } + +export function createMockWorkspaceConfiguration(): IMock { + const mockWorkspaceConfiguration = Mock.ofType(); + + mockWorkspaceConfiguration + .setup((x) => x.update(It.isAny(), It.isAny(), It.isAny())) + .returns(async () => {}); + + return mockWorkspaceConfiguration; +} diff --git a/src/test/mocks/webview.ts b/src/test/mocks/webview.ts new file mode 100644 index 00000000..dc904bfc --- /dev/null +++ b/src/test/mocks/webview.ts @@ -0,0 +1,24 @@ +import { IMock, Mock } from "typemoq"; + +import { WebviewMessage } from "../../ui/types"; +import TmcWebview from "../../ui/webview"; + +export interface WebviewMockValues { + postMessage: (...messages: WebviewMessage[]) => void; +} + +export function createWebviewMock(): [IMock, WebviewMockValues] { + const values: WebviewMockValues = { + postMessage: () => {}, + }; + const mock = setupMockValues(values); + return [mock, values]; +} + +function setupMockValues(values: WebviewMockValues): IMock { + const mock = Mock.ofType(); + + mock.setup((x) => x.postMessage).returns(() => values.postMessage); + + return mock; +} diff --git a/src/test/mocks/workspaceManager.ts b/src/test/mocks/workspaceManager.ts new file mode 100644 index 00000000..03b30942 --- /dev/null +++ b/src/test/mocks/workspaceManager.ts @@ -0,0 +1,52 @@ +import { Ok, Result } from "ts-results"; +import { IMock, It, Mock } from "typemoq"; + +import WorkspaceManager, { WorkspaceExercise } from "../../api/workspaceManager"; +import { workspaceExercises } from "../fixtures/workspaceManager"; + +export interface WorkspaceManagerMockValues { + activeCourse?: string; + activeExercise?: Readonly; + closeExercises: Result; + getExerciseByPath: Readonly | undefined; + getExercisesByCoursePythonCourse: ReadonlyArray; + setExercises: Result; + uriIsExercise: boolean; +} + +export function createWorkspaceMangerMock(): [IMock, WorkspaceManagerMockValues] { + const values: WorkspaceManagerMockValues = { + activeCourse: undefined, + activeExercise: undefined, + closeExercises: Ok.EMPTY, + getExerciseByPath: undefined, + getExercisesByCoursePythonCourse: workspaceExercises, + setExercises: Ok.EMPTY, + uriIsExercise: true, + }; + const mock = setupMockValues(values); + + return [mock, values]; +} + +function setupMockValues(values: WorkspaceManagerMockValues): IMock { + const mock = Mock.ofType(); + + mock.setup((x) => x.activeCourse).returns(() => values.activeCourse); + mock.setup((x) => x.activeExercise).returns(() => values.activeExercise); + + mock.setup((x) => x.closeCourseExercises(It.isAny(), It.isAny())).returns( + async () => values.closeExercises, + ); + + mock.setup((x) => x.getExerciseByPath(It.isAny())).returns(() => values.getExerciseByPath); + + mock.setup((x) => x.getExercisesByCourseSlug(It.isValue("test-python-course"))).returns( + () => values.getExercisesByCoursePythonCourse, + ); + + mock.setup((x) => x.setExercises(It.isAny())).returns(async () => values.setExercises); + mock.setup((x) => x.uriIsExercise(It.isAny())).returns(() => values.uriIsExercise); + + return mock; +} diff --git a/src/ui/templateEngine.ts b/src/ui/templateEngine.ts index 7f519e3c..5319ae61 100644 --- a/src/ui/templateEngine.ts +++ b/src/ui/templateEngine.ts @@ -12,7 +12,7 @@ import { TMC_BACKEND_URL } from "../config/constants"; import Resources from "../config/resources"; import { getProgressBar, parseTestResultsText } from "../utils/"; -import { CourseDetails, Login, MyCourses, Settings, Webview, Welcome } from "./templates"; +import { CourseDetails, Login, MyCourses, Webview, Welcome } from "./templates"; import { TemplateData } from "./types"; export default class TemplateEngine { @@ -283,19 +283,11 @@ export default class TemplateEngine { cspSource: webview.cspSource, script: MyCourses.script, }); - case "settings": - return Webview.render({ - children: Settings.component(), - cssBlob, - cspSource: webview.cspSource, - script: Settings.script, - }); case "welcome": return Webview.render({ children: Welcome.component({ ...templateData, - newTreeView: webview.asWebviewUri(templateData.newTreeView), - actionsExplorer: webview.asWebviewUri(templateData.actionsExplorer), + exerciseDecorations: webview.asWebviewUri(templateData.exerciseDecorations), tmcLogoFile: webview.asWebviewUri(templateData.tmcLogoFile), }), cspSource: webview.cspSource, diff --git a/src/ui/templates/MyCourses.jsx b/src/ui/templates/MyCourses.jsx index 33c8d819..37fe4f67 100644 --- a/src/ui/templates/MyCourses.jsx +++ b/src/ui/templates/MyCourses.jsx @@ -108,15 +108,31 @@ const component = (props) => { return (
-

My Courses

-
- +
+
+

My Courses

+
+ +
+
+
+
TMC Exercises Location
+
+ Currently your exercises () are located at: +
+

+


+                    

+ +
{courses.length > 0 ? ( courses.map(mapCourse).join("") @@ -135,6 +151,10 @@ const script = () => { document.getElementById("add-new-course").addEventListener("click", () => { vscode.postMessage({ type: "addCourse" }); }); + const changeTMCDataPathButton = document.getElementById("change-tmc-datapath-btn"); + changeTMCDataPathButton.addEventListener("click", () => { + vscode.postMessage({ type: "changeTmcDataPath" }); + }); /** * @param {number} courseId @@ -218,6 +238,8 @@ const script = () => { }); } + const tmcDataPath = document.getElementById("tmc-data-path"); + const tmcDataSize = document.getElementById("tmc-data-size"); window.addEventListener("message", (event) => { for (let i = 0; i < event.data.length; i++) { /**@type {import("../types").WebviewMessage} */ @@ -240,6 +262,11 @@ const script = () => { setCourseDisabledStatus(message.courseId, message.disabled); break; } + case "setTmcDataFolder": { + tmcDataPath.innerText = message.path; + tmcDataSize.innerText = message.diskSize; + break; + } } } }); diff --git a/src/ui/templates/Settings.d.ts b/src/ui/templates/Settings.d.ts deleted file mode 100644 index 214b10f7..00000000 --- a/src/ui/templates/Settings.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export function component(): unknown; -export function script(): void; diff --git a/src/ui/templates/Settings.jsx b/src/ui/templates/Settings.jsx deleted file mode 100644 index 4a8b71b9..00000000 --- a/src/ui/templates/Settings.jsx +++ /dev/null @@ -1,285 +0,0 @@ -// Required for compilation, even if not referenced -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const createElement = require("./templateUtils").createElement; - -/*eslint-env browser*/ - -// Provided by VSCode vebview at runtime -/*global acquireVsCodeApi*/ - -/** - * Template for Settings page. - * @param {import("./Settings").SettingsProps} props - */ -const component = () => { - return ( -
-
-
-

TMC Settings

-
Here you can change TMC extension settings.
-

Settings are saved automatically.

-
-
-
-
-
-
-
TMC Data
-
- Currently your TMC data () is located at: -
-

- -

- -
-
-
-
-
Extension logging
- - -
- - -
-
-
-
-
-
Hide Exercise Meta Files
-

- Hides exercise meta files, i.e. .available_points.json, - .tmc_test_result.json, tmc, etc. -

-
- - -
-
-
-
-
-
Download old submission
-

- Downloads the latest submission of an exercise by default, when - available. -

-

- - Note: this feature is currently unavailable and will be added - back in future version. - -

-
- - -
-
-
-
-
-
Update exercises automatically
-

- Downloads any available updates to exercises automatically. If - turned off, shows a notification instead. -

-
- - -
-
-
-
-
-
Editor: Open Side By Side Direction
-

- Controls the default direction of editors that are opened side by - side (e.g. from the explorer). By default, editors will open on the - right hand side of the currently active one. If changed to down, the - editors will open below the currently active one. -

- -
-
-
-
-
Insider version
-

- Toggle this on if you wish to use and test TestMyCode extension - upcoming features and enhancements. New features might not be - visible by eye and might crash the extension. You can always opt-out - if something isn't working and use the stable version. -

-

- If you encounter any issues, please report them to our Github{" "} - - issues - - . -

-
- - -
-
-
-
-
-
- ); -}; - -const script = () => { - const vscode = acquireVsCodeApi(); - - const changeTMCDataPathButton = document.getElementById("change-tmc-datapath-btn"); - changeTMCDataPathButton.addEventListener("click", () => { - vscode.postMessage({ type: "changeTmcDataPath" }); - }); - - const logLevelSelect = document.getElementById("log-level"); - logLevelSelect.addEventListener("input", () => { - const level = logLevelSelect.options[logLevelSelect.selectedIndex].value; - vscode.postMessage({ type: "changeLogLevel", data: level }); - }); - - const showLogsButton = document.getElementById("show-logs-btn"); - showLogsButton.addEventListener("click", () => { - vscode.postMessage({ type: "showLogsToUser" }); - }); - - const openLogsFolderButton = document.getElementById("open-logs-btn"); - openLogsFolderButton.addEventListener("click", () => { - vscode.postMessage({ type: "openLogsFolder" }); - }); - - const hideMetaFilesToggle = document.getElementById("check-meta-files"); - hideMetaFilesToggle.addEventListener("click", (event) => { - hideMetaFilesToggle.disabled = true; - vscode.postMessage({ type: "hideMetaFiles", data: event.target.checked }); - }); - - const downloadOldSubmissionToggle = document.getElementById("download-old-submission"); - downloadOldSubmissionToggle.addEventListener("click", (event) => { - downloadOldSubmissionToggle.disabled = true; - vscode.postMessage({ type: "downloadOldSubmissionSetting", data: event.target.checked }); - }); - - const updateExercisesAutomaticallyToggle = document.getElementById( - "update-exercises-automatically", - ); - updateExercisesAutomaticallyToggle.addEventListener("click", (event) => { - updateExercisesAutomaticallyToggle.disabled = true; - vscode.postMessage({ - type: "updateExercisesAutomaticallySetting", - data: event.target.checked, - }); - }); - - const openDirectionButton = document.getElementById("open-direction-btn"); - openDirectionButton.addEventListener("click", () => { - updateExercisesAutomaticallyToggle.disabled = true; - vscode.postMessage({ type: "openEditorDirection" }); - }); - - const insiderToggle = document.getElementById("insider-version-toggle"); - insiderToggle.addEventListener("click", (event) => { - insiderToggle.disabled = true; - vscode.postMessage({ type: "insiderStatus", data: event.target.checked }); - }); - - const tmcDataPath = document.getElementById("tmc-data-path"); - const tmcDataSize = document.getElementById("tmc-data-size"); - window.addEventListener("message", (event) => { - for (let i = 0; i < event.data.length; i++) { - /**@type {import("../types").WebviewMessage} */ - const message = event.data[i]; - switch (message.command) { - case "setBooleanSetting": - switch (message.setting) { - case "downloadOldSubmission": - downloadOldSubmissionToggle.checked = message.enabled; - downloadOldSubmissionToggle.disabled = false; - break; - case "hideMetaFiles": - hideMetaFilesToggle.checked = message.enabled; - hideMetaFilesToggle.disabled = false; - break; - // No insider versions available - // case "insider": - // insiderToggle.checked = message.enabled; - // insiderToggle.disabled = false; - // break; - case "updateExercisesAutomatically": - updateExercisesAutomaticallyToggle.checked = message.enabled; - updateExercisesAutomaticallyToggle.disabled = false; - break; - } - break; - case "setLogLevel": { - console.log(message.level); - for (let i = 0; i < logLevelSelect.options.length; i++) { - if (logLevelSelect.options[i].value === message.level) { - logLevelSelect.selectedIndex = i; - break; - } - } - break; - } - case "setTmcDataFolder": - tmcDataPath.innerText = message.path; - tmcDataSize.innerText = message.diskSize; - break; - } - } - }); -}; - -export { component, script }; diff --git a/src/ui/templates/Welcome.d.ts b/src/ui/templates/Welcome.d.ts index e1cbb31a..ad860700 100644 --- a/src/ui/templates/Welcome.d.ts +++ b/src/ui/templates/Welcome.d.ts @@ -3,8 +3,7 @@ import { Uri } from "vscode"; interface WelcomeProps { /** Version if the plugin to show to the user. */ version: string; - newTreeView: Uri; - actionsExplorer: Uri; + exerciseDecorations: Uri; tmcLogoFile: Uri; } diff --git a/src/ui/templates/Welcome.jsx b/src/ui/templates/Welcome.jsx index f2686b8f..c2ff84b6 100644 --- a/src/ui/templates/Welcome.jsx +++ b/src/ui/templates/Welcome.jsx @@ -11,7 +11,7 @@ const createElement = require("./templateUtils").createElement; /** * @param {import("./Welcome").WelcomeProps} props */ -function component({ version }) { +function component({ version, exerciseDecorations }) { return (
@@ -47,10 +47,10 @@ function component({ version }) {
-

What's new in 2.0?

+

What's new in 2.1?

- Here is a little overview of latest features. To see all the changes for + Here is a short overview of latest features. To see all the changes for version {version}, please refer to the{" "} CHANGELOG @@ -59,39 +59,42 @@ function component({ version }) {

-
- Caution! It is not recommended to downgrade the extension version to below - 2.0! -
-
-
-

Exercise management

-

- The main focus of this release is to introduce a new management system for - exercises on disk. These features have been moved to TMC-langs that is the - centralized utility for main TestMyCode related functions. This new - architecture reduces previously existing major overheads and allows for some - performance optimizations. -

+

Exercise Decorations

- Unfortunately this change required for all exercises to be migrated to a new - default location. If you would like to, you may now move them back again - from the settings view. + You can now see completed and partially completed (i.e. received some + points) exercises with an icon on the course workspace. +
+ You can also see if the deadline has been exceeded and if the exercise has + been removed or renamed in the course. By hovering on an exercise with an + icon, you should see an information message explaining the status.

+
-

Improved exercise downloads

+

Migrated to VSCode Settings

- Following the change to exercise management, downloading new exercises is - now significantly faster. + Old custom settings view has been removed in favor of VS Code's native + settings page.
+ With this change, users can now specify course specific settings when having + the course workspace open in VSCode by going to Settings and selecting the + Workspace tab.
+ Settings defined in the Workspace tab are of higher priority than those + defined in the User scope. When adding a new course, it will copy the + settings defined in the User scope to the Workspace.

-

Improvements to TMC workspace

+

Automatically download old submissions

- TMC Workspace files have now been located in a consistent location separate - from where exercises are. This allows for more user-friendly experience when - moving the exercises folder or using the extension data wipe command. + Prior to version 2.0.0, downloaded exercises were restored to the state of + their latest submission. +
+ This feature has been re-enabled and the extension automatically downloads + your latest submission if enabled in settings.

diff --git a/src/ui/templates/index.ts b/src/ui/templates/index.ts index cd30adc6..32cc1089 100644 --- a/src/ui/templates/index.ts +++ b/src/ui/templates/index.ts @@ -1,8 +1,7 @@ import * as CourseDetails from "./CourseDetails.jsx"; import * as Login from "./Login.jsx"; import * as MyCourses from "./MyCourses.jsx"; -import * as Settings from "./Settings.jsx"; import * as Webview from "./Webview.jsx"; import * as Welcome from "./Welcome.jsx"; -export { CourseDetails, Login, MyCourses, Settings, Webview, Welcome }; +export { CourseDetails, Login, MyCourses, Webview, Welcome }; diff --git a/src/utils/apiData.ts b/src/utils/apiData.ts new file mode 100644 index 00000000..1255be11 --- /dev/null +++ b/src/utils/apiData.ts @@ -0,0 +1,36 @@ +import { LocalCourseExercise } from "../api/storage"; +import { CourseExercise, Exercise } from "../api/types"; +import { + LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, + LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER, + LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER, +} from "../config/constants"; + +/** + * Takes exercise arrays from two different endpoints and attempts to resolve them into + * `LocalCourseExercise`. Uses common default values, if matching id is not found from + * `courseExercises`. + */ +export function combineApiExerciseData( + exercises: Exercise[], + courseExercises: CourseExercise[], +): LocalCourseExercise[] { + const exercisePointsMap = new Map(courseExercises.map((x) => [x.id, x])); + return exercises.map((x) => { + const match = exercisePointsMap.get(x.id); + const passed = x.completed; + const awardedPointsFallback = passed + ? LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER + : LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER; + return { + id: x.id, + availablePoints: + match?.available_points.length ?? LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, + awardedPoints: match?.awarded_points.length ?? awardedPointsFallback, + name: x.name, + deadline: x.deadline, + passed: x.completed, + softDeadline: x.soft_deadline, + }; + }); +} diff --git a/src/utils/env.d.ts b/src/utils/env.d.ts new file mode 100644 index 00000000..adb100f6 --- /dev/null +++ b/src/utils/env.d.ts @@ -0,0 +1,17 @@ +export type Platform = + | "linux32" + | "linux64" + | "linuxarm" + | "linuxarm64" + | "macos32" + | "macos64" + | "macosarm64" + | "windows32" + | "windows64" + | "other"; + +export function getAllLangsCLIs(version: string): string[]; + +export function getLangsCLIForPlatform(platform: Platform, version: string): string; + +export function getPlatform(): Platform; diff --git a/src/utils/env.ts b/src/utils/env.js similarity index 69% rename from src/utils/env.ts rename to src/utils/env.js index cd688907..ac153627 100644 --- a/src/utils/env.ts +++ b/src/utils/env.js @@ -1,16 +1,29 @@ -export type Platform = - | "linux32" - | "linux64" - | "linuxarm64" - | "linuxarm" - | "windows32" - | "windows64" - | "macosarm64" - | "macos64" - | "macos32" - | "other"; +//@ts-check -export function getPlatform(): Platform { +const uniq = require("lodash").uniq; + +/**@type {import("./env").Platform[]} */ +const allPlatforms = [ + "linux32", + "linux64", + "linuxarm", + "linuxarm64", + "macos32", + "macos64", + "macosarm64", + "windows32", + "windows64", + "other", +]; + +/**@type {import("./env").getAllLangsCLIs} */ +function getAllLangsCLIs(version) { + const allCLIs = allPlatforms.map((x) => getLangsCLIForPlatform(x, version)); + return uniq(allCLIs); +} + +/**@type {import("./env").getPlatform} */ +function getPlatform() { const platform = process.platform; const arch = process.arch; if (platform === "linux") { @@ -34,7 +47,8 @@ export function getPlatform(): Platform { return "other"; } -export function getLangsCLIForPlatform(platform: Platform, version: string): string { +/**@type {import("./env").getLangsCLIForPlatform} */ +function getLangsCLIForPlatform(platform, version) { switch (platform) { case "linux32": return `tmc-langs-cli-i686-unknown-linux-gnu-${version}`; @@ -58,3 +72,5 @@ export function getLangsCLIForPlatform(platform: Platform, version: string): str return `tmc-langs-cli-x86_64-unknown-linux-gnu-${version}`; } } + +module.exports = { getAllLangsCLIs, getLangsCLIForPlatform, getPlatform }; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 7106a3ea..b8f8925e 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -13,8 +13,8 @@ const channel = `[${OUTPUT_CHANNEL_NAME}]`; export class Logger { static output: OutputChannel | undefined; - static configure(level: LogLevel): void { - this.level = level; + static configure(level?: LogLevel): void { + this.level = level ?? LogLevel.Errors; } static get level(): LogLevel { diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 81e796fa..ec68f460 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -2,11 +2,9 @@ import * as fs from "fs-extra"; import * as fetch from "node-fetch"; import * as path from "path"; import { Err, Ok, Result } from "ts-results"; -import * as vscode from "vscode"; import { FeedbackQuestion } from "../actions/types"; import { SubmissionFeedbackQuestion } from "../api/types"; -import Resources from "../config/resources"; import { ConnectionError } from "../errors"; import { Logger } from "./logger"; @@ -66,18 +64,6 @@ export async function downloadFile( return Ok.EMPTY; } -/** - * Checks if currently open vscode workspace file path and given courseName path matches. - * - * @param resources - * @param courseName - */ -export function isCorrectWorkspaceOpen(resources: Resources, courseName: string): boolean { - const currentWorkspaceFile = vscode.workspace.workspaceFile; - const tmcWorkspaceFile = vscode.Uri.file(resources.getWorkspaceFilePath(courseName)); - return currentWorkspaceFile?.toString() === tmcWorkspaceFile.toString(); -} - /** * Await this to pause execution for an amount of time * @param millis diff --git a/src/window/index.ts b/src/window/index.ts index bad3f282..72bc7f54 100644 --- a/src/window/index.ts +++ b/src/window/index.ts @@ -47,7 +47,9 @@ function getPythonPath( : extension.exports.settings.getExecutionCommand(document.uri); return execCommand.join(" "); } else { - return actionContext.settings.getWorkspaceSettings()?.get("python.pythonPath"); + return actionContext.workspaceManager + .getWorkspaceSettings() + .get("python.pythonPath"); } } catch (error) { const message = "Error while fetching python executable string"; diff --git a/webpack.config.js b/webpack.config.js index fee9b573..d0f0b502 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -31,6 +31,7 @@ const config = () => { entry: { extension: "./src/extension.ts", "testBundle.test": glob.sync("./src/test/**/*.test.ts"), + "integration.spec": glob.sync("./src/test-integration/**/*.spec.ts"), }, output: { path: path.resolve(__dirname, "dist"), @@ -56,6 +57,14 @@ const config = () => { infrastructureLogging: { level: "log", }, + optimization: { + splitChunks: { + chunks: "all", + name(module, chunks) { + return chunks.find((x) => x.name === "extension") ? "lib" : "testlib"; + }, + }, + }, module: { rules: [ {