Skip to content

Commit d922e06

Browse files
committed
Populate the sidebar
1 parent 7606a2e commit d922e06

File tree

4 files changed

+311
-0
lines changed

4 files changed

+311
-0
lines changed

src/commands/tests_sidebar.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {
2+
NotificationRequest,
3+
nova,
4+
Process,
5+
wrapCommand,
6+
} from "../nova_utils.ts";
7+
import TestsDataProvider, { PathFile } from "../tests/TestsDataProvider.ts";
8+
9+
export function registerLearnMore() {
10+
return nova.commands.register(
11+
"co.gwil.deno.sidebars.tests.commands.learn",
12+
wrapCommand(learnMore),
13+
);
14+
15+
function learnMore() {
16+
const options = {
17+
args: ["https://deno.land/manual/testing#running-tests"],
18+
};
19+
const process = new Process("/usr/bin/open", options);
20+
process.start();
21+
}
22+
}
23+
24+
export function registerRefresh(testsDataProvider: TestsDataProvider) {
25+
return nova.commands.register(
26+
"co.gwil.deno.sidebars.tests.commands.refresh",
27+
wrapCommand(refresh),
28+
);
29+
30+
function refresh() {
31+
if (nova.workspace.path) {
32+
testsDataProvider.updateFiles();
33+
} else {
34+
const informativeNotificationRequest = new NotificationRequest(
35+
"co.gwil.deno.notifications.missingWorkspacePathForTests",
36+
);
37+
informativeNotificationRequest.title =
38+
"The tests sidebar is unavailable.";
39+
informativeNotificationRequest.body =
40+
"Open a project in which to look for tests.";
41+
nova.notifications.add(informativeNotificationRequest);
42+
}
43+
}
44+
}
45+
46+
export function registerRunAll(testsDataProvider: TestsDataProvider) {
47+
return nova.commands.register(
48+
"co.gwil.deno.sidebars.tests.commands.runAll",
49+
wrapCommand(runAll),
50+
);
51+
52+
function runAll() {
53+
testsDataProvider.runTests();
54+
}
55+
}
56+
57+
export function registerRun(testsDataProvider: TestsDataProvider) {
58+
return nova.commands.register(
59+
"co.gwil.deno.sidebars.tests.commands.run",
60+
wrapCommand(run),
61+
);
62+
63+
function run() {
64+
testsDataProvider.runTests(
65+
testsDataProvider.treeView.selection.filter((item) =>
66+
item instanceof PathFile
67+
) as PathFile[],
68+
);
69+
}
70+
}

src/nova_deno.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
CanNotEnsureError,
1616
makeClientDisposable,
1717
} from "./client_disposable.ts";
18+
import { registerTestsSidebar } from "./tests/register.ts";
1819

1920
const compositeDisposable = new CompositeDisposable();
2021
const taskDisposable = new CompositeDisposable();
@@ -53,6 +54,8 @@ export async function activate() {
5354

5455
compositeDisposable.add(registerEditorWatcher());
5556

57+
compositeDisposable.add(registerTestsSidebar());
58+
5659
const configFileWatchingDisposables = watchConfigFiles(
5760
nova.workspace.path,
5861
configFilenames,

src/tests/TestsDataProvider.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import {
2+
nova,
3+
Process,
4+
TreeDataProvider,
5+
TreeItem,
6+
TreeItemCollapsibleState,
7+
TreeView,
8+
} from "../nova_utils.ts";
9+
10+
// This is duplicate code.
11+
interface Element {
12+
toTreeItem: () => TreeItem;
13+
children: Element[];
14+
}
15+
// This is also duplicate code.
16+
class Header implements Element {
17+
content: string;
18+
children: [];
19+
constructor(content: string) {
20+
this.content = content;
21+
this.children = [];
22+
}
23+
24+
toTreeItem() {
25+
return new TreeItem(this.content);
26+
}
27+
}
28+
class Test implements Element {
29+
name: string;
30+
passed: boolean;
31+
children: [];
32+
constructor(name: string, passed: boolean) {
33+
this.name = name;
34+
this.passed = passed;
35+
this.children = [];
36+
}
37+
38+
toTreeItem() {
39+
const item = new TreeItem(this.name);
40+
// item.image = TODO
41+
return item;
42+
}
43+
}
44+
export class TestFile implements Element {
45+
path: string;
46+
children: Test[];
47+
shouldDisambiguate: boolean;
48+
49+
constructor(path: string, children: [], shouldDisambiguate: boolean) {
50+
this.path = path;
51+
this.children = children;
52+
this.shouldDisambiguate = shouldDisambiguate;
53+
}
54+
55+
get filename(): string {
56+
return nova.path.basename(this.path);
57+
}
58+
59+
get extension(): string {
60+
return nova.path.extname(this.path);
61+
}
62+
63+
toTreeItem() {
64+
const relativePath: string = nova.workspace.relativizePath(this.path);
65+
const item = new TreeItem(
66+
this.shouldDisambiguate ? relativePath : this.filename,
67+
);
68+
69+
item.image = "__filetype" + this.extension;
70+
item.collapsibleState = TreeItemCollapsibleState.Expanded;
71+
item.contextValue = "file";
72+
return item;
73+
}
74+
}
75+
76+
export default class TestsDataProvider implements TreeDataProvider<Element> {
77+
// See https://deno.land/manual/testing#running-tests.
78+
static FILE_REGEXP = /^.*[_.]?test\.(ts|tsx|mts|js|mjs|jsx|cjs|cts)$/;
79+
treeView: TreeView<Element>;
80+
files: TestFile[];
81+
82+
constructor() {
83+
this.treeView = new TreeView("co.gwil.deno.sidebars.tests.sections.1", {
84+
dataProvider: this,
85+
});
86+
this.files = [];
87+
88+
if (nova.workspace.path) {
89+
this.updateFiles();
90+
}
91+
}
92+
93+
static findTests(directoryPath: string): string[] {
94+
function isAccessibleDirectory(path: string) {
95+
try {
96+
nova.fs.listdir(path);
97+
} catch {
98+
return false;
99+
}
100+
return true;
101+
}
102+
103+
const tests: string[] = [];
104+
for (const name of nova.fs.listdir(directoryPath)) {
105+
const path = nova.path.join(directoryPath, name);
106+
if (isAccessibleDirectory(path)) {
107+
let internalTests = null;
108+
try {
109+
internalTests = TestsDataProvider.findTests(path);
110+
} catch {
111+
continue;
112+
}
113+
tests.push(...internalTests);
114+
} else if (name.match(TestsDataProvider.FILE_REGEXP)) {
115+
tests.push(path);
116+
}
117+
}
118+
return tests;
119+
}
120+
121+
updateFiles() {
122+
if (!nova.workspace.path) {
123+
throw new Error("This function requires a workspace path.");
124+
}
125+
126+
const paths = TestsDataProvider.findTests(nova.workspace.path);
127+
for (const path of paths) {
128+
if (this.files.find((file) => file.path == path)) {
129+
continue;
130+
}
131+
132+
this.files.push(
133+
new TestFile(
134+
path,
135+
[],
136+
false, // TODO
137+
),
138+
);
139+
}
140+
}
141+
142+
runTests() {
143+
if (!nova.workspace.path) {
144+
throw new Error("This function requires a workspace path.");
145+
}
146+
147+
const paths = this.files.map((file) => file.path);
148+
149+
const options = {
150+
args: ["deno", "test", ...paths],
151+
cwd: nova.workspace.path,
152+
};
153+
const denoProcess = new Process("/usr/bin/env", options);
154+
155+
// This must be very fragile!
156+
const INTRODUCTION_REGEXP = /running \d+ tests from ([^]+)/;
157+
const TEST_REGEXP = /([^]+) \.\.\. (FAILED|ok) \(\d+[A-Za-z]+\)$/;
158+
let currentFile: TestFile | null = null;
159+
const output: TestFile[] = [];
160+
denoProcess.onStdout((line) => {
161+
console.log(line);
162+
// remove newline
163+
line = line.slice(0, -1);
164+
const introduction = line.match(INTRODUCTION_REGEXP);
165+
const test = line.match(TEST_REGEXP);
166+
167+
if (introduction) {
168+
if (currentFile) {
169+
output.push(currentFile);
170+
}
171+
const [, relativePath] = introduction;
172+
currentFile = new TestFile(
173+
nova.path.normalize(
174+
nova.path.join(nova.workspace.path!, relativePath),
175+
),
176+
[],
177+
false, // TODO
178+
);
179+
} else if (test) {
180+
const [, name, message] = test;
181+
const passed = message == "ok";
182+
183+
if (!currentFile) {
184+
throw new Error("Unexpected logging. Oops.");
185+
}
186+
187+
currentFile.children.push(new Test(name, passed));
188+
}
189+
});
190+
191+
const onExit = new Promise((resolve) => {
192+
denoProcess.onDidExit(() => {
193+
// TODO: explore the dangers regarding tests that take long to execute
194+
this.files = output;
195+
resolve(output);
196+
});
197+
});
198+
199+
denoProcess.start();
200+
return onExit;
201+
}
202+
203+
getChildren(element: Element | null): Element[] {
204+
if (element == null) {
205+
if (nova.workspace.path) {
206+
return this.files;
207+
} else {
208+
return [new Header("Open a Deno project to see test results.")];
209+
}
210+
}
211+
return element.children;
212+
}
213+
214+
getTreeItem(element: Element): TreeItem {
215+
return element.toTreeItem();
216+
}
217+
}

src/tests/register.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { CompositeDisposable } from "../nova_utils.ts";
2+
import {
3+
registerLearnMore,
4+
registerRefresh,
5+
} from "../commands/tests_sidebar.ts";
6+
import TestsDataProvider from "./TestsDataProvider.ts";
7+
8+
let testsDataProvider: TestsDataProvider | null = null;
9+
10+
export function registerTestsSidebar() {
11+
const testsDisposable = new CompositeDisposable();
12+
13+
const dataProvider = new TestsDataProvider();
14+
testsDataProvider = dataProvider;
15+
testsDisposable.add(dataProvider.treeView);
16+
17+
testsDisposable.add(registerLearnMore());
18+
testsDisposable.add(registerRefresh(testsDataProvider));
19+
20+
return testsDisposable;
21+
}

0 commit comments

Comments
 (0)