diff --git a/CHANGELOG.md b/CHANGELOG.md index 79da8400..893ccf61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,3 +101,9 @@ Resulting in a folder structure like the following: - Continue an incomplete tutorial started in the same workspace. Choose the "continue" path from the start screen. Progress is stored in local storage in the workspace. ![continue tutorial](./docs/images/continue-tutorial.png) + +## [0.5.0] + +- Show error messages in the webview UI + +![fail message in webview](./docs/images/fail-message-in-webview.png) diff --git a/docs/images/fail-message-in-webview.png b/docs/images/fail-message-in-webview.png new file mode 100644 index 00000000..c4d5488e Binary files /dev/null and b/docs/images/fail-message-in-webview.png differ diff --git a/src/channel/index.ts b/src/channel/index.ts index 07e4298f..fd7c09cf 100644 --- a/src/channel/index.ts +++ b/src/channel/index.ts @@ -14,6 +14,7 @@ import { openWorkspace, checkWorkspaceEmpty } from '../services/workspace' import { readFile } from 'fs' import { join } from 'path' import { promisify } from 'util' +import { showOutput } from '../services/testRunner/output' import { WORKSPACE_ROOT } from '../environment' const readFileAsync = promisify(readFile) @@ -300,7 +301,9 @@ class Channel implements Channel { // update progress when a level is deemed complete in the client await this.context.progress.syncProgress(action.payload.progress) return - + case 'EDITOR_OPEN_LOGS': + const channel = action.payload.channel + await showOutput(channel) default: logger(`No match for action type: ${actionType}`) return diff --git a/src/editor/commands.ts b/src/editor/commands.ts index 19b96585..002b8211 100644 --- a/src/editor/commands.ts +++ b/src/editor/commands.ts @@ -3,7 +3,7 @@ import * as TT from 'typings/tutorial' import * as vscode from 'vscode' import createTestRunner from '../services/testRunner' import { setupActions } from '../actions/setupActions' -import createWebView from '../webview' +import createWebView from '../services/webview' import logger from '../services/logger' export const COMMANDS = { @@ -62,9 +62,9 @@ export const createCommands = ({ extensionPath, workspaceState }: CreateCommandP // send test pass message back to client webview.send({ type: 'TEST_PASS', payload: { position } }) }, - onFail: (position: T.Position, message: string) => { + onFail: (position: T.Position, failSummary: T.TestFail): void => { // send test fail message back to client with failure message - webview.send({ type: 'TEST_FAIL', payload: { position, message } }) + webview.send({ type: 'TEST_FAIL', payload: { position, fail: failSummary } }) }, onError: (position: T.Position) => { // TODO: send test error message back to client diff --git a/src/environment.ts b/src/environment.ts index 89a61125..6fd41f4d 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -10,7 +10,7 @@ export type Env = 'test' | 'local' | 'development' | 'production' export const NODE_ENV: Env = process.env.NODE_ENV || 'production' // toggle logging in development -export const LOG = false +export const LOG = true // error logging tool export const SENTRY_DSN: string | null = null diff --git a/src/services/testRunner/formatOutput.ts b/src/services/testRunner/formatOutput.ts index 00a2d8dc..2f538df4 100644 --- a/src/services/testRunner/formatOutput.ts +++ b/src/services/testRunner/formatOutput.ts @@ -4,7 +4,7 @@ import { ParserOutput, Fail } from './parser' // export const formatSuccessOutput = (tap: ParserOutput): string => {} export const formatFailOutput = (tap: ParserOutput): string => { - let output = `FAILED TESTS\n` + let output = `FAILED TEST LOG\n` tap.failed.forEach((fail: Fail) => { const details = fail.details ? `\n${fail.details}\n` : '' const logs = fail.logs ? `\n${fail.logs.join('\n')}\n` : '' diff --git a/src/services/testRunner/index.ts b/src/services/testRunner/index.ts index 182abc5e..57cea72a 100644 --- a/src/services/testRunner/index.ts +++ b/src/services/testRunner/index.ts @@ -5,12 +5,12 @@ import logger from '../logger' import parser from './parser' import { debounce, throttle } from './throttle' import onError from '../sentry/onError' -import { clearOutput, displayOutput } from './output' +import { clearOutput, addOutput } from './output' import { formatFailOutput } from './formatOutput' interface Callbacks { onSuccess(position: T.Position): void - onFail(position: T.Position, message: string): void + onFail(position: T.Position, failSummary: T.TestFail): void onRun(position: T.Position): void onError(position: T.Position): void } @@ -51,20 +51,24 @@ const createTestRunner = (config: TT.TutorialTestRunnerConfig, callbacks: Callba const tap = parser(stdout || '') - displayOutput({ channel: logChannelName, text: tap.logs.join('\n'), show: false }) + addOutput({ channel: logChannelName, text: tap.logs.join('\n'), show: false }) if (stderr) { // FAIL also trigger stderr if (stdout && stdout.length && !tap.ok) { - const firstFailMessage = tap.failed[0].message - callbacks.onFail(position, firstFailMessage) + const firstFail = tap.failed[0] + const failSummary = { + title: firstFail.message || 'Test Failed', + description: firstFail.details || 'Unknown error', + } + callbacks.onFail(position, failSummary) const output = formatFailOutput(tap) - displayOutput({ channel: failChannelName, text: output, show: true }) + addOutput({ channel: failChannelName, text: output, show: true }) return } else { callbacks.onError(position) // open terminal with error string - displayOutput({ channel: failChannelName, text: stderr, show: true }) + addOutput({ channel: failChannelName, text: stderr, show: true }) return } } diff --git a/src/services/testRunner/output.ts b/src/services/testRunner/output.ts index 916b6000..c390c9ba 100644 --- a/src/services/testRunner/output.ts +++ b/src/services/testRunner/output.ts @@ -9,22 +9,25 @@ const getOutputChannel = (name: string): vscode.OutputChannel => { return channels[name] } -interface DisplayOutput { +interface ChannelOutput { channel: string text: string show?: boolean } -export const displayOutput = (params: DisplayOutput) => { +export const addOutput = (params: ChannelOutput) => { const channel = getOutputChannel(params.channel) channel.clear() - channel.show(params.show || false) channel.append(params.text) } +export const showOutput = (channelName: string) => { + const channel = getOutputChannel(channelName) + channel.show() +} + export const clearOutput = (channelName: string) => { const channel = getOutputChannel(channelName) - channel.show(false) channel.clear() channel.hide() } diff --git a/src/webview/index.ts b/src/services/webview/index.ts similarity index 98% rename from src/webview/index.ts rename to src/services/webview/index.ts index e6fd4a3e..bfd27ef9 100644 --- a/src/webview/index.ts +++ b/src/services/webview/index.ts @@ -1,7 +1,7 @@ import * as path from 'path' import { Action } from 'typings' import * as vscode from 'vscode' -import Channel from '../channel' +import Channel from '../../channel' import render from './render' interface ReactWebViewProps { diff --git a/src/webview/render.ts b/src/services/webview/render.ts similarity index 98% rename from src/webview/render.ts rename to src/services/webview/render.ts index 1bf5bf6a..63b2680f 100644 --- a/src/webview/render.ts +++ b/src/services/webview/render.ts @@ -1,7 +1,7 @@ import { JSDOM } from 'jsdom' import * as path from 'path' import * as vscode from 'vscode' -import onError from '../services/sentry/onError' +import onError from '../sentry/onError' const getNonce = (): string => { let text = '' diff --git a/typings/index.d.ts b/typings/index.d.ts index 78c3d501..a80e8e00 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -42,6 +42,7 @@ export interface TestStatus { type: 'success' | 'warning' | 'error' | 'loading' title: string content?: string + timeout?: number } export interface MachineContext { @@ -116,3 +117,8 @@ export interface ProcessEvent { description: string status: 'RUNNING' | 'SUCCESS' | 'FAIL' | 'ERROR' } + +export type TestFail = { + title: string + description: string +} diff --git a/web-app/src/components/Message/index.tsx b/web-app/src/components/Message/index.tsx index c0f428d2..8d3ccc05 100644 --- a/web-app/src/components/Message/index.tsx +++ b/web-app/src/components/Message/index.tsx @@ -11,6 +11,7 @@ interface Props { closeable?: boolean onClose?: () => void handleClose?: () => void + children?: React.ReactElement | null } const Message = (props: Props) => { @@ -30,7 +31,10 @@ const Message = (props: Props) => { onClose={onClose} shape={props.shape} > - {props.content} +
+
{props.content}
+
{props.children}
+
) } diff --git a/web-app/src/components/ProcessMessages/TestMessage.tsx b/web-app/src/components/ProcessMessages/TestMessage.tsx index 57b7eb2e..2eebaa3e 100644 --- a/web-app/src/components/ProcessMessages/TestMessage.tsx +++ b/web-app/src/components/ProcessMessages/TestMessage.tsx @@ -5,7 +5,7 @@ import { css, jsx } from '@emotion/core' const durations = { success: 1000, - warning: 4500, + warning: 20000, error: 4500, loading: 300000, } @@ -24,7 +24,7 @@ const useTimeout = ({ duration, key }: { duration: number; key: string }) => { return timeoutClose } -const TestMessage = (props: T.TestStatus) => { +const TestMessage = (props: T.TestStatus & { children?: React.ReactElement | null }) => { const duration = durations[props.type] const timeoutClose = useTimeout({ duration, key: props.title }) return ( @@ -36,7 +36,9 @@ const TestMessage = (props: T.TestStatus) => { size="medium" closeable={props.type !== 'loading'} content={props.content} - /> + > + {props.children} + ) } diff --git a/web-app/src/components/ProcessMessages/index.tsx b/web-app/src/components/ProcessMessages/index.tsx index c1614c40..80334568 100644 --- a/web-app/src/components/ProcessMessages/index.tsx +++ b/web-app/src/components/ProcessMessages/index.tsx @@ -1,12 +1,14 @@ import Message from '../Message' import * as React from 'react' import * as T from 'typings' +import Button from '../Button' import { css, jsx } from '@emotion/core' import TestMessage from './TestMessage' interface Props { testStatus?: T.TestStatus | null processes: T.ProcessEvent[] + onOpenLogs?: (channel: string) => void } const styles = { @@ -17,9 +19,21 @@ const styles = { } // display a list of active processes -const ProcessMessages = ({ processes, testStatus }: Props) => { +const ProcessMessages = ({ processes, testStatus, onOpenLogs }: Props) => { if (testStatus) { - return + return ( + + {testStatus.type === 'warning' ? ( + + ) : null} + + ) } if (!processes.length) { return null diff --git a/web-app/src/containers/Tutorial/components/Level.tsx b/web-app/src/containers/Tutorial/components/Level.tsx index 38f7a8bd..2ee7a26b 100644 --- a/web-app/src/containers/Tutorial/components/Level.tsx +++ b/web-app/src/containers/Tutorial/components/Level.tsx @@ -96,6 +96,7 @@ interface Props { testStatus: T.TestStatus | null onContinue(): void onLoadSolution(): void + onOpenLogs(channel: string): void } const Level = ({ @@ -107,6 +108,7 @@ const Level = ({ status, onContinue, onLoadSolution, + onOpenLogs, processes, testStatus, }: Props) => { @@ -170,7 +172,7 @@ const Level = ({ {(testStatus || processes.length > 0) && (
- +
)} diff --git a/web-app/src/containers/Tutorial/index.tsx b/web-app/src/containers/Tutorial/index.tsx index 4c04f660..c0dd156f 100644 --- a/web-app/src/containers/Tutorial/index.tsx +++ b/web-app/src/containers/Tutorial/index.tsx @@ -32,6 +32,10 @@ const TutorialPage = (props: PageProps) => { props.send({ type: 'STEP_SOLUTION_LOAD' }) } + const onOpenLogs = (channel: string): void => { + props.send({ type: 'OPEN_LOGS', payload: { channel } }) + } + const steps = levelData.steps.map((step: TT.Step) => { // label step status for step component let status: T.ProgressStatus = 'INCOMPLETE' @@ -61,6 +65,7 @@ const TutorialPage = (props: PageProps) => { status={progress.levels[position.levelId] ? 'COMPLETE' : 'ACTIVE'} onContinue={onContinue} onLoadSolution={onLoadSolution} + onOpenLogs={onOpenLogs} processes={processes} testStatus={testStatus} /> diff --git a/web-app/src/services/state/actions/editor.ts b/web-app/src/services/state/actions/editor.ts index e3121018..da0f2e97 100644 --- a/web-app/src/services/state/actions/editor.ts +++ b/web-app/src/services/state/actions/editor.ts @@ -1,4 +1,4 @@ -import * as CR from 'typings' +import * as T from 'typings' import * as TT from 'typings/tutorial' import * as selectors from '../../selectors' @@ -8,7 +8,7 @@ export default (editorSend: any) => ({ type: 'EDITOR_STARTUP', }) }, - configureNewTutorial(context: CR.MachineContext) { + configureNewTutorial(context: T.MachineContext) { editorSend({ type: 'EDITOR_TUTORIAL_CONFIG', payload: { @@ -17,7 +17,7 @@ export default (editorSend: any) => ({ }, }) }, - continueConfig(context: CR.MachineContext) { + continueConfig(context: T.MachineContext) { editorSend({ type: 'EDITOR_TUTORIAL_CONTINUE_CONFIG', payload: { @@ -26,7 +26,7 @@ export default (editorSend: any) => ({ }, }) }, - loadLevel(context: CR.MachineContext): void { + loadLevel(context: T.MachineContext): void { const level: TT.Level = selectors.currentLevel(context) const step: TT.Step | null = selectors.currentStep(context) // load step actions @@ -41,7 +41,7 @@ export default (editorSend: any) => ({ }, }) }, - loadStep(context: CR.MachineContext): void { + loadStep(context: T.MachineContext): void { const step: TT.Step | null = selectors.currentStep(context) if (step && step.setup) { // load step actions @@ -58,7 +58,7 @@ export default (editorSend: any) => ({ }) } }, - editorLoadSolution(context: CR.MachineContext): void { + editorLoadSolution(context: T.MachineContext): void { const step: TT.Step | null = selectors.currentStep(context) // tell editor to load solution commit if (step && step.solution) { @@ -74,7 +74,7 @@ export default (editorSend: any) => ({ }) } }, - syncLevelProgress(context: CR.MachineContext): void { + syncLevelProgress(context: T.MachineContext): void { editorSend({ type: 'EDITOR_SYNC_PROGRESS', payload: { @@ -95,4 +95,10 @@ export default (editorSend: any) => ({ type: 'EDITOR_REQUEST_WORKSPACE', }) }, + editorOpenLogs(context: T.MachineContext, event: T.MachineEvent): void { + editorSend({ + type: 'EDITOR_OPEN_LOGS', + payload: { channel: event.payload.channel }, + }) + }, }) diff --git a/web-app/src/services/state/actions/testNotify.ts b/web-app/src/services/state/actions/testNotify.ts index 5b509adc..c5f7bda3 100644 --- a/web-app/src/services/state/actions/testNotify.ts +++ b/web-app/src/services/state/actions/testNotify.ts @@ -20,8 +20,8 @@ const testActions: ActionFunctionMap = { testFail: assign({ testStatus: (context, event) => ({ type: 'warning', - title: 'Fail!', - content: event.payload.message, + title: event.payload.fail.title, + content: event.payload.fail.description, }), }), // @ts-ignore diff --git a/web-app/src/services/state/machine.ts b/web-app/src/services/state/machine.ts index 8b91d813..ccf7589a 100644 --- a/web-app/src/services/state/machine.ts +++ b/web-app/src/services/state/machine.ts @@ -170,6 +170,9 @@ export const createMachine = (options: any) => { STEP_SOLUTION_LOAD: { actions: ['editorLoadSolution'], }, + OPEN_LOGS: { + actions: ['editorOpenLogs'], + }, }, }, TestRunning: { diff --git a/web-app/stories/Checkbox.stories.tsx b/web-app/stories/Checkbox.stories.tsx index c8c935ca..ae1d1431 100644 --- a/web-app/stories/Checkbox.stories.tsx +++ b/web-app/stories/Checkbox.stories.tsx @@ -1,6 +1,5 @@ import { storiesOf } from '@storybook/react' import React from 'react' -import { css, jsx } from '@emotion/core' import Checkbox from '../src/components/Checkbox' import SideBarDecorator from './utils/SideBarDecorator'