diff --git a/src/actions/runTest.ts b/src/actions/runTest.ts deleted file mode 100644 index 5af4183e..00000000 --- a/src/actions/runTest.ts +++ /dev/null @@ -1,138 +0,0 @@ -import * as vscode from 'vscode' -import node from '../services/node' - -// TODO: use tap parser to make it easier to support other test runners - -// ensure only latest run_test action is taken -let currentId = 0 - -// quick solution to prevent processing multiple results -// NOTE: may be possible to kill child process early -const shouldExitEarly = (processId: number): boolean => { - return currentId !== processId -} - -let channel: vscode.OutputChannel - -const getOutputChannel = (name: string): vscode.OutputChannel => { - if (!channel) { - channel = vscode.window.createOutputChannel(name) - } - return channel -} - -interface Props { - onSuccess(): void - onFail(): void - onRun(): void - onError(): void -} - -async function runTest({onSuccess, onFail, onRun, onError}: Props): Promise { - console.log('------------------- run test ------------------') - // increment process id - const processId = ++currentId - - onRun() - - const outputChannelName = 'Test Output' - - // TODO: verify test runner for args - // jest CLI docs https://jestjs.io/docs/en/cli - const testArgs = [ - '--json', - '--onlyChanged', - '--env=node', - '--maxConcurrency=4', - '--maxWorkers=4' - ] - - const commandLine = `npm test -- ${testArgs.join(' ')}` - - try { - // capture position early on test start - // in case position changes - const {stdout} = await node.exec(commandLine) - if (shouldExitEarly(processId)) { - // exit early - return - } - - if (stdout) { - const lines = stdout.split(/\r{0,1}\n/) - for (const line of lines) { - if (line.length === 0) { - continue - } - - const regExp = /^{\"numFailedTestSuites/ - const matches = regExp.exec(line) - if (matches && matches.length) { - const result = JSON.parse(line) - - if (result.success) { - if (shouldExitEarly(processId)) { - // exit early - return - } - onSuccess() - } else { - console.log('NOT SUCCESS?') - } - } - } - } - } catch (err) { - if (shouldExitEarly(processId)) { - // exit early - return - } - // error contains output & error message - // output can be parsed as json - const {stdout, stderr} = err - console.log('TEST FAILED', stdout) - - if (!stdout) { - console.error('SOMETHING WENT WRONG WITH A PASSING TEST') - onError() - return - } - // test runner failed - channel = getOutputChannel(outputChannelName) - - if (stdout) { - const lines = stdout.split(/\r{0,1}\n/) - - for (const line of lines) { - if (line.length === 0) { - continue - } - - const dataRegExp = /^{\"numFailedTestSuites"/ - const matches = dataRegExp.exec(line) - - if (matches && matches.length) { - const result = JSON.parse(line) - const firstError = result.testResults.find((t: any) => t.status === 'failed') - - if (firstError) { - if (shouldExitEarly(processId)) { - // exit early - return - } - onFail() - } else { - console.error('NOTE: PARSER DID NOT WORK FOR ', line) - } - } - } - } - - if (stderr) { - channel.show(false) - channel.appendLine(stderr) - } - } -} - -export default runTest \ No newline at end of file diff --git a/src/actions/setupActions.ts b/src/actions/setupActions.ts index fed92c67..33aab5fa 100644 --- a/src/actions/setupActions.ts +++ b/src/actions/setupActions.ts @@ -4,34 +4,34 @@ import * as vscode from 'vscode' import * as git from '../services/git' import node from '../services/node' -interface ErrorMessageFilter { - [lang: string]: { - [key: string]: string - } -} +// interface ErrorMessageFilter { +// [lang: string]: { +// [key: string]: string +// } +// } // TODO: should be loaded on startup based on language -const commandErrorMessageFilter: ErrorMessageFilter = { - JAVASCRIPT: { - 'node-gyp': 'Error running npm setup command' - } -} +// const commandErrorMessageFilter: ErrorMessageFilter = { +// JAVASCRIPT: { +// 'node-gyp': 'Error running npm setup command' +// } +// } // TODO: pass command and command name down for filtering. Eg. JAVASCRIPT, 'npm install' -const runCommands = async (commands: string[], language: string = 'JAVASCRIPT') => { +const runCommands = async (commands: string[]) => { for (const command of commands) { const {stdout, stderr} = await node.exec(command) if (stderr) { console.error(stderr) // language specific error messages from running commands - const filteredMessages = Object.keys(commandErrorMessageFilter[language]) - for (const message of filteredMessages) { - if (stderr.match(message)) { - // ignored error - throw new Error('Error running setup command') - } - } + // const filteredMessages = Object.keys(commandErrorMessageFilter[language]) + // for (const message of filteredMessages) { + // if (stderr.match(message)) { + // // ignored error + // throw new Error('Error running setup command') + // } + // } } console.log(`run command: ${command}`, stdout) } @@ -45,7 +45,8 @@ const disposeWatcher = (listener: string) => { delete watchers[listener] } -const setupActions = async (workspaceRoot: vscode.WorkspaceFolder, {commands, commits, files, listeners}: G.StepActions): Promise => { +const setupActions = async (workspaceRoot: vscode.WorkspaceFolder, actions: G.StepActions): Promise => { + const {commands, commits, files, listeners} = actions // run commits if (commits) { for (const commit of commits) { @@ -55,16 +56,36 @@ const setupActions = async (workspaceRoot: vscode.WorkspaceFolder, {commands, co // run file watchers (listeners) if (listeners) { + console.log('listeners') for (const listener of listeners) { if (!watchers[listener]) { const pattern = new vscode.RelativePattern( vscode.workspace.getWorkspaceFolder(workspaceRoot.uri)!, listener ) - watchers[listener] = vscode.workspace.createFileSystemWatcher( + console.log(pattern) + const listen = vscode.workspace.createFileSystemWatcher( pattern ) + watchers[listener] = listen watchers[listener].onDidChange(() => { + console.log('onDidChange') + // trigger save + vscode.commands.executeCommand('coderoad.run_test', null, () => { + // cleanup watcher on success + disposeWatcher(listener) + }) + }) + watchers[listener].onDidCreate(() => { + console.log('onDidCreate') + // trigger save + vscode.commands.executeCommand('coderoad.run_test', null, () => { + // cleanup watcher on success + disposeWatcher(listener) + }) + }) + watchers[listener].onDidDelete(() => { + console.log('onDidDelete') // trigger save vscode.commands.executeCommand('coderoad.run_test', null, () => { // cleanup watcher on success diff --git a/src/actions/tutorialConfig.ts b/src/actions/tutorialConfig.ts index 9093cfe6..058cf93a 100644 --- a/src/actions/tutorialConfig.ts +++ b/src/actions/tutorialConfig.ts @@ -1,7 +1,8 @@ import * as G from 'typings/graphql' import * as vscode from 'vscode' import * as git from '../services/git' -import langaugeMap from '../editor/languageMap' +import languageMap from '../editor/languageMap' +import {COMMANDS} from '../editor/commands' interface TutorialConfigParams { config: G.TutorialConfig, @@ -19,10 +20,29 @@ const tutorialConfig = async ({config, alreadyConfigured, }: TutorialConfigParam await git.setupRemote(config.repo.uri) } + vscode.commands.executeCommand(COMMANDS.CONFIG_TEST_RUNNER, config.testRunner) + + const fileFormats = config.testRunner.fileFormats + + // verify if file test should run based on document saved + const shouldRunTest = (document: vscode.TextDocument): boolean => { + // must be a file + if (document.uri.scheme !== 'file') { + return false + } + // must configure with file formatss + if (fileFormats && fileFormats.length) { + const fileFormat: G.FileFormat = languageMap[document.languageId] + if (!fileFormats.includes(fileFormat)) { + return false + } + } + return true + } + // setup onSave hook vscode.workspace.onDidSaveTextDocument((document: vscode.TextDocument) => { - const fileFormat: G.FileFormat = langaugeMap[document.languageId] - if (document.uri.scheme === 'file' && config.fileFormats.includes(fileFormat)) { + if (shouldRunTest(document)) { vscode.commands.executeCommand('coderoad.run_test') } }) diff --git a/src/editor/ReactWebView.ts b/src/editor/ReactWebView.ts index 91483acb..ae49424f 100644 --- a/src/editor/ReactWebView.ts +++ b/src/editor/ReactWebView.ts @@ -161,7 +161,6 @@ class ReactWebView { } } - // set CSP (content security policy) to grant permission to local files const cspMeta: HTMLMetaElement = document.createElement('meta') cspMeta.httpEquiv = 'Content-Security-Policy' diff --git a/src/editor/commands.ts b/src/editor/commands.ts index 2ed22d99..1b157364 100644 --- a/src/editor/commands.ts +++ b/src/editor/commands.ts @@ -1,10 +1,12 @@ +import * as G from 'typings/graphql' import * as vscode from 'vscode' import ReactWebView from './ReactWebView' -import runTest from '../actions/runTest' +import createTestRunner, {Payload} from '../services/testRunner' -const COMMANDS = { +export const COMMANDS = { START: 'coderoad.start', OPEN_WEBVIEW: 'coderoad.open_webview', + CONFIG_TEST_RUNNER: 'coderoad.config_test_runner', RUN_TEST: 'coderoad.run_test', SET_CURRENT_STEP: 'coderoad.set_current_step', } @@ -19,6 +21,7 @@ export const createCommands = ({extensionPath, workspaceState, workspaceRoot}: C // React panel webview let webview: any let currentStepId = '' + let testRunner: any return { // initialize @@ -49,37 +52,37 @@ export const createCommands = ({extensionPath, workspaceState, workspaceRoot}: C // setup 1x1 horizontal layout webview.createOrShow() }, - [COMMANDS.SET_CURRENT_STEP]: ({stepId}: {stepId: string}) => { - // NOTE: as async, may sometimes be inaccurate - // set from last setup stepAction - currentStepId = stepId - }, - [COMMANDS.RUN_TEST]: (current: {stepId: string} | undefined, onSuccess: () => void) => { - console.log('-------- command.run_test ------ ') - // use stepId from client, or last set stepId - const payload = {stepId: current ? current.stepId : currentStepId} - runTest({ - onSuccess: () => { + [COMMANDS.CONFIG_TEST_RUNNER]: (config: G.TutorialTestRunner) => { + testRunner = createTestRunner(config, { + onSuccess: (payload: Payload) => { // send test pass message back to client - webview.send({type: 'TEST_PASS', payload}) - onSuccess() vscode.window.showInformationMessage('PASS') + webview.send({type: 'TEST_PASS', payload}) }, - onFail: () => { + onFail: (payload: Payload) => { // send test fail message back to client - webview.send({type: 'TEST_FAIL', payload}) vscode.window.showWarningMessage('FAIL') + webview.send({type: 'TEST_FAIL', payload}) }, - onError: () => { - console.log('COMMAND TEST_ERROR') + onError: (payload: Payload) => { // send test error message back to client webview.send({type: 'TEST_ERROR', payload}) }, - onRun: () => { + onRun: (payload: Payload) => { // send test run message back to client webview.send({type: 'TEST_RUNNING', payload}) } }) }, + [COMMANDS.SET_CURRENT_STEP]: ({stepId}: Payload) => { + // NOTE: as async, may sometimes be inaccurate + // set from last setup stepAction + currentStepId = stepId + }, + [COMMANDS.RUN_TEST]: (current: Payload | undefined, onSuccess: () => void) => { + // use stepId from client, or last set stepId + const payload: Payload = {stepId: current ? current.stepId : currentStepId} + testRunner(payload, onSuccess) + }, } } diff --git a/src/editor/index.ts b/src/editor/index.ts index d35a7ab5..7d6a48b1 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -29,7 +29,6 @@ class Editor { } private activateCommands = (): void => { - // set workspace root for node executions const workspaceRoots: vscode.WorkspaceFolder[] | undefined = vscode.workspace.workspaceFolders if (!workspaceRoots || !workspaceRoots.length) { diff --git a/src/editor/outputChannel.ts b/src/editor/outputChannel.ts new file mode 100644 index 00000000..a2eeb224 --- /dev/null +++ b/src/editor/outputChannel.ts @@ -0,0 +1,10 @@ +import * as vscode from 'vscode' + +let channel: vscode.OutputChannel + +export const getOutputChannel = (name: string): vscode.OutputChannel => { + if (!channel) { + channel = vscode.window.createOutputChannel(name) + } + return channel +} \ No newline at end of file diff --git a/src/services/testRunner/index.ts b/src/services/testRunner/index.ts new file mode 100644 index 00000000..ad32b1c6 --- /dev/null +++ b/src/services/testRunner/index.ts @@ -0,0 +1,68 @@ +import node from '../../services/node' +import {getOutputChannel} from '../../editor/outputChannel' +import parser from './parser' +import {setLatestProcess, isLatestProcess} from './throttle' + +export interface Payload { + stepId: string +} + +interface Callbacks { + onSuccess(payload: Payload): void + onFail(payload: Payload): void + onRun(payload: Payload): void + onError(payload: Payload): void +} + +interface TestRunnerConfig { + command: string +} + +const createTestRunner = (config: TestRunnerConfig, callbacks: Callbacks) => { + + const outputChannelName = 'TEST_OUTPUT' + + return async (payload: Payload, onSuccess?: () => void): Promise => { + console.log('------------------- run test ------------------') + + // flag as running + callbacks.onRun(payload) + + let result: {stdout: string | undefined, stderr: string | undefined} + try { + result = await node.exec(config.command) + } catch (err) { + result = err + } + const {stdout, stderr} = result + + // simple way to throttle requests + if (!stdout) {return } + + if (stderr) { + callbacks.onError(payload) + + // open terminal with error string + const channel = getOutputChannel(outputChannelName) + channel.show(false) + channel.appendLine(stderr) + return + } + + // pass or fail? + const tap = parser(stdout) + if (tap.ok) { + callbacks.onSuccess(payload) + if (onSuccess) {onSuccess()} + } else { + // TODO: parse failure message + // open terminal with failed test string + // const channel = getOutputChannel(outputChannelName) + // channel.show(false) + // channel.appendLine(testsFailed.message) + callbacks.onFail(payload) + } + } +} + +export default createTestRunner \ No newline at end of file diff --git a/src/services/testRunner/parser.ts b/src/services/testRunner/parser.ts new file mode 100644 index 00000000..e5f6967b --- /dev/null +++ b/src/services/testRunner/parser.ts @@ -0,0 +1,15 @@ +interface ParserOutput { + ok: boolean +} + +const parser = (text: string): ParserOutput => { + const lines = text.split('\n') + for (const line of lines) { + if (line.match(/^not ok /)) { + return {ok: false} + } + } + return {ok: true} +} + +export default parser \ No newline at end of file diff --git a/src/services/testRunner/throttle.ts b/src/services/testRunner/throttle.ts new file mode 100644 index 00000000..cf096b93 --- /dev/null +++ b/src/services/testRunner/throttle.ts @@ -0,0 +1,8 @@ +// ensure only latest run_test action is taken +let currentId = 0 + +export const setLatestProcess = () => currentId++ + +// quick solution to prevent processing multiple results +// NOTE: may be possible to kill child process early +export const isLatestProcess = (processId: number): boolean => currentId === processId diff --git a/typings/graphql.d.ts b/typings/graphql.d.ts index 42a04d09..76f3d893 100644 --- a/typings/graphql.d.ts +++ b/typings/graphql.d.ts @@ -136,8 +136,6 @@ export type StepActions = { listeners?: Maybe> } -export type TestRunner = 'JEST' - /** A tutorial for use in VSCode CodeRoad */ export type Tutorial = { __typename?: 'Tutorial' @@ -157,8 +155,7 @@ export type TutorialVersionArgs = { /** Configure environment in editor for git, testing & parsing files */ export type TutorialConfig = { __typename?: 'TutorialConfig' - testRunner: TestRunner - fileFormats: Array + testRunner: TutorialTestRunner repo: TutorialRepo } @@ -197,6 +194,12 @@ export type TutorialSummary = { description: Scalars['String'] } +export type TutorialTestRunner = { + __typename?: 'TutorialTestRunner' + command: Scalars['String'] + fileFormats?: Maybe> +} + /** A version of a tutorial */ export type TutorialVersion = { __typename?: 'TutorialVersion' @@ -308,7 +311,7 @@ export type ResolversTypes = { TutorialSummary: ResolverTypeWrapper TutorialData: ResolverTypeWrapper TutorialConfig: ResolverTypeWrapper - TestRunner: TestRunner + TutorialTestRunner: ResolverTypeWrapper FileFormat: FileFormat TutorialRepo: ResolverTypeWrapper TutorialInit: ResolverTypeWrapper @@ -345,7 +348,7 @@ export type ResolversParentTypes = { TutorialSummary: TutorialSummary TutorialData: TutorialData TutorialConfig: TutorialConfig - TestRunner: TestRunner + TutorialTestRunner: TutorialTestRunner FileFormat: FileFormat TutorialRepo: TutorialRepo TutorialInit: TutorialInit @@ -514,8 +517,7 @@ export type TutorialConfigResolvers< ContextType = any, ParentType extends ResolversParentTypes['TutorialConfig'] = ResolversParentTypes['TutorialConfig'] > = { - testRunner?: Resolver - fileFormats?: Resolver, ParentType, ContextType> + testRunner?: Resolver repo?: Resolver } @@ -553,6 +555,14 @@ export type TutorialSummaryResolvers< description?: Resolver } +export type TutorialTestRunnerResolvers< + ContextType = any, + ParentType extends ResolversParentTypes['TutorialTestRunner'] = ResolversParentTypes['TutorialTestRunner'] + > = { + command?: Resolver + fileFormats?: Resolver>, ParentType, ContextType> + } + export type TutorialVersionResolvers< ContextType = any, ParentType extends ResolversParentTypes['TutorialVersion'] = ResolversParentTypes['TutorialVersion'] @@ -604,6 +614,7 @@ export type Resolvers = { TutorialInit?: TutorialInitResolvers TutorialRepo?: TutorialRepoResolvers TutorialSummary?: TutorialSummaryResolvers + TutorialTestRunner?: TutorialTestRunnerResolvers TutorialVersion?: TutorialVersionResolvers User?: UserResolvers } @@ -634,3 +645,4 @@ export interface IntrospectionResultData { }[] } } + diff --git a/typings/index.d.ts b/typings/index.d.ts index fb075999..8edb29f4 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -74,6 +74,7 @@ export interface MachineStateSchema { TestRunning: {} TestPass: {} TestFail: {} + TestError: {} StepNext: {} LevelComplete: {} } diff --git a/typings/lib.d.ts b/typings/lib.d.ts new file mode 100644 index 00000000..f8f66f94 --- /dev/null +++ b/typings/lib.d.ts @@ -0,0 +1,13 @@ +declare module 'tap-parser' { + type TapParserOutput = { + ok: boolean + count: number + pass: number + plan: { + start: number + end: number + } + } + const Parser: any + export default Parser +} diff --git a/web-app/.vscode/settings.json b/web-app/.vscode/settings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/web-app/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/web-app/src/services/apollo/queries/tutorial.ts b/web-app/src/services/apollo/queries/tutorial.ts index a364b4cb..c339f914 100644 --- a/web-app/src/services/apollo/queries/tutorial.ts +++ b/web-app/src/services/apollo/queries/tutorial.ts @@ -12,8 +12,10 @@ export default gql` } data { config { - testRunner - fileFormats + testRunner { + command + fileFormats + } repo { uri branch diff --git a/web-app/src/services/state/machine.ts b/web-app/src/services/state/machine.ts index db5ab0f0..a15e8eae 100644 --- a/web-app/src/services/state/machine.ts +++ b/web-app/src/services/state/machine.ts @@ -134,9 +134,15 @@ export const machine = Machine