Skip to content

Commit bf3644b

Browse files
committed
Populate the sidebar
This commit includes a change to the `LICENSE` file, which may be undesired.
1 parent 7606a2e commit bf3644b

File tree

5 files changed

+353
-0
lines changed

5 files changed

+353
-0
lines changed

LICENSE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
MIT License
22

33
Copyright (c) 2021 Sam Gwilym
4+
Copyright (c) 2015-present, Brian Woodward
45

56
Permission is hereby granted, free of charge, to any person obtaining a copy
67
of this software and associated documentation files (the "Software"), to deal

src/commands/tests.sidebar.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {
2+
NotificationRequest,
3+
nova,
4+
Process,
5+
wrapCommand,
6+
} from "../nova_utils.ts";
7+
import TestsDataProvider, {
8+
UnexpectedLogError,
9+
} from "../tests/TestsDataProvider.ts";
10+
11+
export function registerLearnMore() {
12+
return nova.commands.register(
13+
"co.gwil.deno.sidebars.tests.commands.learn",
14+
wrapCommand(learnMore),
15+
);
16+
17+
function learnMore() {
18+
const options = {
19+
args: ["https://deno.land/manual/testing#running-tests"],
20+
};
21+
const process = new Process("/usr/bin/open", options);
22+
process.start();
23+
}
24+
}
25+
26+
export function registerRefresh(testsDataProvider: TestsDataProvider) {
27+
return nova.commands.register(
28+
"co.gwil.deno.sidebars.tests.commands.refresh",
29+
wrapCommand(refresh),
30+
);
31+
32+
function refresh() {
33+
if (nova.workspace.path) {
34+
testsDataProvider.updateFiles(nova.workspace.path);
35+
testsDataProvider.treeView.reload();
36+
} else {
37+
const informativeNotificationRequest = new NotificationRequest(
38+
"co.gwil.deno.notifications.missingWorkspacePathForTests",
39+
);
40+
informativeNotificationRequest.title =
41+
"The tests sidebar is unavailable.";
42+
informativeNotificationRequest.body =
43+
"Open a project in which to look for tests.";
44+
nova.notifications.add(informativeNotificationRequest);
45+
}
46+
}
47+
}
48+
49+
export function registerRunAll(testsDataProvider: TestsDataProvider) {
50+
return nova.commands.register(
51+
"co.gwil.deno.sidebars.tests.commands.runAll",
52+
wrapCommand(runAll),
53+
);
54+
55+
async function runAll() {
56+
try {
57+
await testsDataProvider.runTests();
58+
testsDataProvider.treeView.reload();
59+
} catch (e) {
60+
if (e instanceof UnexpectedLogError) {
61+
const dissatisfactoryOutcomeNotificationRequest =
62+
new NotificationRequest(
63+
"co.gwil.deno.notifications.fragileTestsSidebar",
64+
);
65+
dissatisfactoryOutcomeNotificationRequest.title =
66+
"The tests' outcomes are unknown.";
67+
dissatisfactoryOutcomeNotificationRequest.body =
68+
"This feature is fragile, and relies on the extension's compatibility with your particular Deno version. If you're using an outdated version of Deno, or an outdated version of the extension, try updating.";
69+
nova.notifications.add(dissatisfactoryOutcomeNotificationRequest);
70+
} else {
71+
// shown as a Nova message regardless; doesn't produce a crash
72+
throw e;
73+
}
74+
}
75+
}
76+
}
77+
78+
export function registerRun(testsDataProvider: TestsDataProvider) {
79+
return nova.commands.register(
80+
"co.gwil.deno.sidebars.tests.commands.run",
81+
wrapCommand(run),
82+
);
83+
84+
function run() {
85+
testsDataProvider.runTests();
86+
}
87+
}

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: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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 UnexpectedLogError extends Error {}
45+
export class TestFile implements Element {
46+
path: string;
47+
children: Test[];
48+
shouldDisambiguate: boolean;
49+
50+
constructor(path: string, children: [], shouldDisambiguate: boolean) {
51+
this.path = path;
52+
this.children = children;
53+
this.shouldDisambiguate = shouldDisambiguate;
54+
}
55+
56+
get filename(): string {
57+
return nova.path.basename(this.path);
58+
}
59+
60+
get extension(): string {
61+
return nova.path.extname(this.path);
62+
}
63+
64+
toTreeItem() {
65+
const relativePath: string = nova.workspace.relativizePath(this.path);
66+
const item = new TreeItem(
67+
this.shouldDisambiguate ? relativePath : this.filename,
68+
);
69+
70+
item.image = "__filetype" + this.extension;
71+
item.collapsibleState = TreeItemCollapsibleState.Expanded;
72+
item.contextValue = "file";
73+
return item;
74+
}
75+
}
76+
77+
export default class TestsDataProvider implements TreeDataProvider<Element> {
78+
// See https://deno.land/manual/testing#running-tests.
79+
static FILE_REGEXP = /^.*[_.]?test\.(ts|tsx|mts|js|mjs|jsx|cjs|cts)$/;
80+
treeView: TreeView<Element>;
81+
files: TestFile[];
82+
83+
constructor() {
84+
this.treeView = new TreeView("co.gwil.deno.sidebars.tests.sections.1", {
85+
dataProvider: this,
86+
});
87+
this.files = [];
88+
89+
if (nova.workspace.path) {
90+
this.updateFiles(nova.workspace.path);
91+
}
92+
}
93+
94+
static findTests(directoryPath: string): string[] {
95+
function isAccessibleDirectory(path: string) {
96+
try {
97+
nova.fs.listdir(path);
98+
} catch {
99+
return false;
100+
}
101+
return true;
102+
}
103+
104+
const tests: string[] = [];
105+
for (const name of nova.fs.listdir(directoryPath)) {
106+
const path = nova.path.join(directoryPath, name);
107+
if (isAccessibleDirectory(path)) {
108+
let internalTests = null;
109+
try {
110+
internalTests = TestsDataProvider.findTests(path);
111+
} catch {
112+
continue;
113+
}
114+
tests.push(...internalTests);
115+
} else if (name.match(TestsDataProvider.FILE_REGEXP)) {
116+
tests.push(path);
117+
}
118+
}
119+
120+
return tests;
121+
}
122+
123+
updateFiles(directoryPath: string) {
124+
const newPaths = TestsDataProvider.findTests(directoryPath);
125+
const existingPaths = this.files.map((file) => file.path);
126+
127+
const toRemove: number[] = [];
128+
for (let i = 0; i < existingPaths.length; i++) {
129+
const path = existingPaths[i];
130+
if (!newPaths.includes(path)) {
131+
toRemove.push(i);
132+
}
133+
}
134+
this.files = this.files.filter((_value, index) =>
135+
!toRemove.includes(index)
136+
);
137+
138+
for (const path of newPaths) {
139+
if (!this.files.find((file) => file.path == path)) {
140+
this.files.push(
141+
new TestFile(
142+
path,
143+
[],
144+
false, // TODO
145+
),
146+
);
147+
}
148+
}
149+
150+
// The above process is carried out instead of replacing the `this.files` property. I prefer this because it does not remove test results from the sidebar.
151+
}
152+
153+
runTests() {
154+
if (!nova.workspace.path) {
155+
throw new Error("This function requires a workspace path.");
156+
}
157+
158+
const paths = this.files.map((file) => file.path);
159+
160+
const options = {
161+
args: ["deno", "test", ...paths],
162+
cwd: nova.workspace.path,
163+
};
164+
const denoProcess = new Process("/usr/bin/env", options);
165+
166+
// This must be very fragile!
167+
const INTRODUCTION_REGEXP = /running \d+ tests from ([^]+)/;
168+
const TEST_REGEXP = /([^]+) \.\.\. (FAILED|ok) \(\d+[A-Za-z]+\)$/;
169+
const output: TestFile[] = [];
170+
denoProcess.onStdout((line) => {
171+
// remove newline
172+
line = line.slice(0, -1);
173+
line = line.replace(
174+
// deno-lint-ignore no-control-regex
175+
/[\u001b\u009b][[\]#;?()]*(?:(?:(?:[^\W_]*;?[^\W_]*)\u0007)|(?:(?:[0-9]{1,4}(;[0-9]{0,4})*)?[~0-9=<>cf-nqrtyA-PRZ]))/g,
176+
"",
177+
);
178+
const introduction = line.match(INTRODUCTION_REGEXP);
179+
const test = line.match(TEST_REGEXP);
180+
181+
if (introduction) {
182+
const [, relativePath] = introduction;
183+
output.push(
184+
new TestFile(
185+
nova.path.normalize(
186+
nova.path.join(nova.workspace.path!, relativePath),
187+
),
188+
[],
189+
false, // TODO
190+
),
191+
);
192+
} else if (test) {
193+
const [, name, message] = test;
194+
const passed = message == "ok";
195+
196+
const currentFile = output.at(-1);
197+
if (!currentFile) {
198+
throw new UnexpectedLogError("Unexpected logging. Oops.");
199+
}
200+
currentFile.children.push(new Test(name, passed));
201+
}
202+
});
203+
204+
const onExit = new Promise((resolve) => {
205+
denoProcess.onDidExit(() => {
206+
// TODO: explore the dangers regarding tests that take long to execute
207+
console.log(
208+
JSON.stringify(
209+
output.map((file) => ({
210+
path: file.path,
211+
tests: file.children.map((test) => test.passed),
212+
})),
213+
undefined,
214+
"\t",
215+
),
216+
);
217+
this.files = output;
218+
resolve(output);
219+
});
220+
});
221+
222+
denoProcess.start();
223+
return onExit;
224+
}
225+
226+
getChildren(element: Element | null): Element[] {
227+
if (element == null) {
228+
if (nova.workspace.path) {
229+
return this.files;
230+
} else {
231+
return [new Header("Open a Deno project to see test results.")];
232+
}
233+
}
234+
return element.children;
235+
}
236+
237+
getTreeItem(element: Element): TreeItem {
238+
return element.toTreeItem();
239+
}
240+
}

src/tests/register.ts

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

0 commit comments

Comments
 (0)