Skip to content

Commit

Permalink
wip: ui
Browse files Browse the repository at this point in the history
  • Loading branch information
ph-fritsche committed Nov 11, 2024
1 parent 938b160 commit ed5f5d9
Show file tree
Hide file tree
Showing 8 changed files with 410 additions and 28 deletions.
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ export async function setupToolboxTester(
a.push(fileServer.close())

await Promise.allSettled(a)
await tester.dispose()
await tester[Symbol.asyncDispose]()
}

const connectCoverageReporter = (
Expand Down
56 changes: 40 additions & 16 deletions src/ui/Tester.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,29 @@ import { EventEmitter } from '../event'
import { getEventDispatch } from '../event/EventEmitter'

type TesterEventMap = {
start: null
stop: null
setFilterSuites: { value: RegExp|undefined }
setFilterTests: { value: RegExp|undefined }
state:
| { key: 'start' }
| { key: 'stop' }
option:
| { key: 'filterSuites', value: RegExp|undefined }
| { key: 'filterTests', value: RegExp|undefined }
newRun: { run: TestRunStack }
fileChange: { path: string }
}

export class Tester extends EventEmitter<TesterEventMap>{
export class Tester extends EventEmitter<TesterEventMap> {
constructor(
readonly manager: TestRunManager,
readonly conductors: Iterable<TestConductor>,
conductors: Iterable<TestConductor>,
readonly watcher: FsWatcher,
protected readonly fileServerUrl: Promise<URL>,
protected readonly mapPathsToTestFiles: (fileserverUrl: URL, subPaths: Iterable<string>) => Iterable<TestFile>,
public testRunIterator: (run: TestRunStack) => Generator<TestSuite>,
protected readonly setExitCode: boolean,
) {
super()
this._conductors = new Set(conductors)
this.init()
}

protected dispatch = getEventDispatch(this)
Expand All @@ -33,14 +39,28 @@ export class Tester extends EventEmitter<TesterEventMap>{
return this._active
}

#runId = -1
#newestRun?: TestRunStack
get runCount() {
return this.#runId + 1
}
get newestRun() {
return this.#newestRun
}

protected _conductors
get conductors() {
return new Set(this._conductors)
}

readonly filterSuites = new Property<RegExp|undefined>(undefined, value => {
this.dispatch('setFilterSuites', {value})
this.dispatch('option', {key: 'filterSuites', value})
if (this._active) {
void this.trigger.activate()
}
})
readonly filterTests = new Property<RegExp|undefined>(undefined, value => {
this.dispatch('setFilterTests', {value})
this.dispatch('option', { key: 'filterTests', value })
if (this._active) {
void this.trigger.activate()
}
Expand All @@ -56,9 +76,10 @@ export class Tester extends EventEmitter<TesterEventMap>{
)
})

init() {
this.onDispose.add(this.watcher.onChange((p: string) => {
this.manager.abort(`Change in ${p}`)
protected init() {
this.onDispose.add(this.watcher.onChange((path: string) => {
this.manager.abort(`Change in ${path}`)
this.dispatch('fileChange', {path})
if (this._active) {
void this.trigger.activate()
}
Expand All @@ -79,9 +100,14 @@ export class Tester extends EventEmitter<TesterEventMap>{
}
}))
}
this.onDispose.add(this.manager.addListener('create', ({run}) => {
this.#runId++
this.#newestRun = run
this.dispatch('newRun', {run})
}))
}
protected onDispose = new Set<() => Promise<void>|void>
async dispose() {
async [Symbol.asyncDispose]() {
const a = []
for (const f of this.onDispose) {
a.push(f())
Expand All @@ -91,7 +117,7 @@ export class Tester extends EventEmitter<TesterEventMap>{

async start() {
this._active = true
this.dispatch('start', null)
this.dispatch('state', {key: 'start'})

await this.watcher.ready

Expand All @@ -100,11 +126,9 @@ export class Tester extends EventEmitter<TesterEventMap>{

async stop() {
this._active = false
this.dispatch('stop', null)
this.dispatch('state', { key: 'stop' })

this.manager.abort('stop')

this.addListener('start', e => e)
}
}

Expand Down
118 changes: 118 additions & 0 deletions src/ui/cli/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React, { useEffect, useMemo } from 'react'
import { Box, Text, useFocus, useInput } from 'ink'
import { useState } from 'react'

export function Input({
id,
title,
initialValue,
onChange,
}: {
id?: string
title: string
initialValue: string
onChange: (s: string) => void
}) {
const {isFocused} = useFocus({id})
const [{value, pos}, set] = useState({value: initialValue, pos: initialValue.length})
useMemo(() => {
if (value !== initialValue) {
set({value: initialValue, pos: initialValue.length})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValue])
useMemo(() => {
if (!isFocused && value !== initialValue) {
onChange(value)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFocused])

useInput((input, key) => {
if (!isFocused) {
return
}

if (key.return) {
if (value !== initialValue) {
onChange(value)
}
} else if (key.ctrl && (key.backspace || key.delete || input === 'w')) {
set({value: '', pos: 0})
} else if (key.backspace || key.delete) {
set(({value, pos}) => ({
value: value.substring(0, pos - 1) + value.substring(pos),
pos: Math.max(0, pos - 1),
}))
} else if (input && !key.ctrl && !key.meta) {
set(({value, pos}) => ({
value: value.substring(0, pos) + input + value.substring(pos),
pos: pos + input.length,
}))
} else if (key.leftArrow) {
set(({value, pos}) => ({value, pos: Math.max(0, pos - 1)}))
} else if (key.rightArrow) {
set(({value, pos}) => ({value, pos: Math.min(value.length, pos + 1)}))
} else if (key.pageUp) {
set(({value}) => ({value, pos: 0}))
} else if (key.pageDown) {
set(({value}) => ({value, pos: value.length}))
}
})

return <Box>
<Box flexDirection="column">
<Text color="grey" dimColor></Text>
<Text color="grey" dimColor></Text>
</Box>
<Box flexDirection="column" width={28}>
<Box>
<Text
bold={isFocused}
>{title}</Text>
<Text color="grey">{''.padEnd(28 - title.length, '─')}</Text>
</Box>
{isFocused
? <FocusedInputValue value={value} pos={pos}/>
: <Box>
<Text>{value.substring(0, 28)}</Text>
<Text color="grey" dimColor>{''.padEnd(28 - value.length, '─')}</Text>
</Box>
}
</Box>
<Box flexDirection="column">
<Text color="grey" dimColor></Text>
<Text color="grey" dimColor></Text>
</Box>
</Box>
}

function FocusedInputValue({
value,
pos,
}: {
value: string
pos: number
}) {
const head = value.substring(pos - Math.min(pos, Math.max(3, 27 - (value.length - pos))), pos)
const cursor = value.substring(pos, pos + 1) || ' '
const tail = value.substring(pos + 1).substring(0, 28 - head.length)

return <Box>
<Text
backgroundColor="yellowBright"
color="blue"
>{head}</Text>
<Text
backgroundColor="blueBright"
color="yellow"
>{cursor}</Text>
<Text
backgroundColor="yellowBright"
color="blue"
>{tail}</Text>
<Text
backgroundColor="yellow"
>{''.padEnd(28 - head.length - cursor.length - tail.length, ' ')}</Text>
</Box>
}
90 changes: 79 additions & 11 deletions src/ui/cli/Main.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import React, { useEffect, useState } from 'react'
import { Box, Newline, Text, useInput } from 'ink'
import { Box, BoxProps, Text, useFocus, useFocusManager, useInput } from 'ink'
import { useTester } from '../TesterContext'
import { TestRunStack } from '../../conductor/TestRun'
import { TestRunStack, TestSuiteStack } from '../../conductor/TestRun'
import { Overview } from './Overview'
import { useSubscribers } from './useSubscribers'
import { TesterStatus } from './TesterStatus'
import { useWindowSize } from './useWindowSize'

export function Main() {
const tester = useTester()

const [currentRun, setCurrentRun] = useState<TestRunStack>()

const { isFocused } = useFocus({ id: '#main', autoFocus: true })
useInput((input) => {
if (input.toLowerCase() === 's') {
if (isFocused && input.toLowerCase() === 's') {
if (tester.active) {
void tester.stop()
} else {
Expand All @@ -21,22 +26,27 @@ export function Main() {
const [, setStatus] = useState(tester.active)
useEffect(() => {
const s = [
tester.addListener('start', () => setStatus(tester.active)),
tester.addListener('stop', () => setStatus(tester.active)),
tester.addListener('state', () => setStatus(tester.active)),
tester.manager.addListener('create', ({run}) => setCurrentRun(run)),
]
return () => s.forEach(f => f())
})

return <Box flexDirection="column">
<Box>
<Text color={tester.active ? 'green' : 'red'}>{tester.active ? 'running' : 'stopped'}</Text></Box>
<Newline/>
{currentRun && <LastResult run={currentRun}></LastResult>}
const {height} = useWindowSize()

return <Box flexDirection="column" minHeight={height -1}>
<TesterStatus/>
{currentRun && <>
<Overview run={currentRun}/>
<Divider/>
<SuitesList run={currentRun}/>
<Divider />
<LastResults run={currentRun} />
</>}
</Box>
}

function LastResult({
function LastResults({
run,
}: {
run: TestRunStack,
Expand All @@ -57,3 +67,61 @@ function LastResult({

return <Box><Text>{last}</Text></Box>
}

function SuitesList({
run,
}: {
run: TestRunStack,
}) {
const tester = useTester()
useSubscribers([
r => run.addListener('skip', () => r()),
r => run.addListener('start', () => r()),
r => run.addListener('done', () => r()),
r => run.addListener('schedule', () => r()),
r => run.addListener('result', () => r()),
r => run.addListener('error', () => r()),
r => tester.manager.addListener('done', () => r()),
], [run])

return <Box flexDirection="column">
{Array.from(run.suites)
.toSorted(([, a], [, b]) => a.title === b.title ? 0 : a.title < b.title ? -1 : 1)
.map(([, s]) => (
<Box key={s.url}>
{getSuiteLine(s)}
</Box>
))
}
</Box>
}

function getSuiteLine(s: TestSuiteStack) {
if (s.index.results.MIXED.size) {
return <Text backgroundColor="magentaBright" color="black">#️⃣ {s.title}</Text>
} else if (s.index.results.fail.size) {
return <Text color="red">{s.title}</Text>
} else if (s.index.results.timeout.size) {
return <Text color="red">{s.title}</Text>
} else if (s.index.results.success.size) {
return <Text color="">{s.title}</Text>
} else {
return <Text> {s.title}</Text>
}
}

function Divider({
vertical = false,
style = 'classic',
}: {
style?: BoxProps['borderStyle']
vertical?: boolean
}) {
return <Box
borderStyle={style}
borderTop={false}
borderLeft={false}
borderBottom={!vertical}
borderRight={vertical}
/>
}
Loading

0 comments on commit ed5f5d9

Please sign in to comment.