forked from eclipse-wildwebdeveloper/wildwebdeveloper
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTestMarkdown.java
More file actions
298 lines (258 loc) · 12.9 KB
/
TestMarkdown.java
File metadata and controls
298 lines (258 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
/*******************************************************************************
* Copyright (c) 2025 Vegard IT GmbH and others.
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Sebastian Thomschke (Vegard IT GmbH) - initial implementation
*******************************************************************************/
package org.eclipse.wildwebdeveloper.tests;
import static org.eclipse.core.resources.IMarker.*;
import static org.eclipse.wildwebdeveloper.markdown.MarkdownDiagnosticsManager.MARKDOWN_MARKER_TYPE;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import java.util.stream.Collectors;
import org.eclipse.core.filebuffers.FileBuffers;
import org.eclipse.core.filebuffers.LocationKind;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IMarker;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.contentassist.ICompletionProposal;
import org.eclipse.lsp4e.LSPEclipseUtils;
import org.eclipse.lsp4e.LanguageServerWrapper;
import org.eclipse.lsp4e.LanguageServiceAccessor;
import org.eclipse.lsp4e.operations.completion.LSContentAssistProcessor;
import org.eclipse.lsp4j.DocumentDiagnosticParams;
import org.eclipse.lsp4j.DocumentDiagnosticReport;
import org.eclipse.lsp4j.services.LanguageServer;
import org.eclipse.ui.PlatformUI;
import org.eclipse.ui.editors.text.TextEditor;
import org.eclipse.ui.ide.IDE;
import org.eclipse.ui.tests.harness.util.DisplayHelper;
import org.eclipse.wildwebdeveloper.Activator;
import org.eclipse.wildwebdeveloper.markdown.MarkdownDiagnosticsManager;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
record MarkdownTest(String markdown, String messagePattern, int severity) {
}
@ExtendWith(AllCleanRule.class)
class TestMarkdown {
private record DiagnosticSpy(AtomicInteger calls,
AtomicReference<CompletableFuture<DocumentDiagnosticReport>> lastFuture, LanguageServer server) {
}
private static DiagnosticSpy newDiagnosticSpy() {
final var calls = new AtomicInteger();
final var lastFuture = new AtomicReference<CompletableFuture<DocumentDiagnosticReport>>();
final Object textDocumentService = Proxy.newProxyInstance(TestMarkdown.class.getClassLoader(),
new Class[] { org.eclipse.lsp4j.services.TextDocumentService.class }, (proxy, method, args) -> {
if ("diagnostic".equals(method.getName()) && args != null && args.length == 1
&& args[0] instanceof DocumentDiagnosticParams) {
calls.incrementAndGet();
final var fut = new CompletableFuture<DocumentDiagnosticReport>();
lastFuture.set(fut);
return fut;
}
if (CompletableFuture.class.isAssignableFrom(method.getReturnType())) {
return CompletableFuture.completedFuture(null);
}
return null;
});
final InvocationHandler serverHandler = (proxy, method, args) -> (switch (method.getName()) {
case "getTextDocumentService" -> textDocumentService;
case "getWorkspaceService" -> null;
case "initialize", "shutdown" -> CompletableFuture.completedFuture(null);
case "exit" -> null;
default -> null;
});
final var server = (LanguageServer) Proxy.newProxyInstance(TestMarkdown.class.getClassLoader(),
new Class[] { LanguageServer.class }, serverHandler);
return new DiagnosticSpy(calls, lastFuture, server);
}
private static boolean waitUpTo(final long timeoutMs, final BooleanSupplier condition)
throws InterruptedException {
final long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
if (condition.getAsBoolean())
return true;
Thread.sleep(20);
}
return condition.getAsBoolean();
}
@Test
void refreshDiagnosticsDoesNothingWhenNoMarkdownBuffersOpen() throws Exception {
final var project = ResourcesPlugin.getWorkspace().getRoot()
.getProject(getClass().getName() + ".nobuf." + System.nanoTime());
project.create(null);
project.open(null);
final IFile file = project.getFile("doc.md");
file.create("# Title\n".getBytes(StandardCharsets.UTF_8), true, false, null);
final var spy = newDiagnosticSpy();
MarkdownDiagnosticsManager.refreshAllOpenMarkdownFiles(spy.server());
// Wait for debounce window + execution time; should still do nothing since no
// Markdown buffer is open.
assertTrue(waitUpTo(2_000, () -> spy.calls().get() == 0),
"Diagnostic requests should not be made when no Markdown buffers are open");
}
@Test
void refreshDiagnosticsIsDedupedWhileInFlight() throws Exception {
final var project = ResourcesPlugin.getWorkspace().getRoot()
.getProject(getClass().getName() + ".dedupe." + System.nanoTime());
project.create(null);
project.open(null);
final IFile file = project.getFile("open.md");
file.create("# Title\n".getBytes(StandardCharsets.UTF_8), true, false, null);
final var mgr = FileBuffers.getTextFileBufferManager();
mgr.connect(file.getFullPath(), LocationKind.IFILE, null);
try {
final var spy = newDiagnosticSpy();
MarkdownDiagnosticsManager.refreshAllOpenMarkdownFiles(spy.server());
assertTrue(waitUpTo(2_000, () -> spy.calls().get() == 1),
"Expected exactly one diagnostic request for the open Markdown buffer");
// Trigger another refresh while the first diagnostic is still in-flight; should
// not start a second diagnostic.
MarkdownDiagnosticsManager.refreshAllOpenMarkdownFiles(spy.server());
assertTrue(waitUpTo(2_000, () -> spy.calls().get() == 1), "Expected in-flight refresh to be de-duplicated");
final var fut = spy.lastFuture().get();
if (fut != null && !fut.isDone()) {
fut.complete(null);
}
} finally {
mgr.disconnect(file.getFullPath(), LocationKind.IFILE, null);
}
}
@Test
void diagnosticsCoverTypicalMarkdownIssues() throws Exception {
var project = ResourcesPlugin.getWorkspace().getRoot().getProject(getClass().getName() + System.nanoTime());
project.create(null);
project.open(null);
final var markerTests = Collections.synchronizedCollection(new ArrayList<MarkdownTest>());
markerTests
.add(new MarkdownTest("Reference link to [an undefined reference][missing-ref]", "No link definition found: 'missing-ref'",
SEVERITY_WARNING));
markerTests.add(
new MarkdownTest("Relative file link: [data](./nonexistent-folder/data.csv)", "File does not exist at path: .*data\\.csv",
SEVERITY_WARNING));
markerTests.add(new MarkdownTest("Broken image: ", "File does not exist at path: .*logo\\.png",
SEVERITY_WARNING));
markerTests.add(new MarkdownTest("Link to missing header in this file: [Jump to Setup](#setup)", "No header found: 'setup'",
SEVERITY_WARNING));
markerTests.add(new MarkdownTest("Link to missing header in another file: [See Guide](./GUIDE.md#installing)",
"Header does not exist in file: installing",
SEVERITY_WARNING));
markerTests.add(new MarkdownTest("Undefined footnote here [^missing-footnote]", "No link definition found: '\\^missing-footnote'",
SEVERITY_WARNING));
markerTests.add(new MarkdownTest("This is a paragraph with an [undefined link][undefined-link].",
"No link definition found: 'undefined-link'",
SEVERITY_WARNING));
markerTests.add(new MarkdownTest("[unused-link]: https://unused-link.com", "Link definition is unused",
SEVERITY_WARNING));
markerTests.add(new MarkdownTest("""
This is a paragraph with a [duplicate link][duplicate-link].
[duplicate-link]: https://duplicate-link.com
[duplicate-link]: https://duplicate-link.com
""", "Link definition for 'duplicate-link' already exists", SEVERITY_ERROR));
final IFile referencedFile = project.getFile("GUIDE.md");
referencedFile.create("".getBytes(), true, false, null);
final IFile file = project.getFile("broken.md");
file.create(markerTests.stream().map(MarkdownTest::markdown).collect(Collectors.joining("\n")).getBytes(StandardCharsets.UTF_8),
true,
false, null);
final var editor = (TextEditor) IDE.openEditor(PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(), file);
final var display = editor.getSite().getShell().getDisplay();
final var doc = editor.getDocumentProvider().getDocument(editor.getEditorInput());
/*
* ensure Markdown Language Server is started and connected
*/
final var markdownLS = new AtomicReference<LanguageServerWrapper>();
DisplayHelper.waitForCondition(display, 10_000, () -> {
markdownLS.set(LanguageServiceAccessor.getStartedWrappers(doc, null, false).stream() //
.filter(w -> "org.eclipse.wildwebdeveloper.markdown".equals(w.serverDefinition.id)) //
.findFirst().orElse(null));
return markdownLS.get() != null //
&& markdownLS.get().isActive() //
&& markdownLS.get().isConnectedTo(LSPEclipseUtils.toUri(doc));
});
// Wait until all expected diagnostics are present (by message fragments)
DisplayHelper.waitForCondition(PlatformUI.getWorkbench().getDisplay(), 15_000, () -> {
try {
final var markers = file.findMarkers(MARKDOWN_MARKER_TYPE, true, IResource.DEPTH_ZERO);
if (markers.length == 0)
return false;
for (final IMarker m : markers) {
final Object msgObj = m.getAttribute(IMarker.MESSAGE);
if (!(msgObj instanceof final String msg))
continue;
markerTests.removeIf(t -> t.severity() == m.getAttribute(IMarker.SEVERITY, -1) &&
msg.matches(t.messagePattern()));
}
return markerTests.isEmpty();
} catch (CoreException e) {
return false;
}
});
assertTrue(markerTests.isEmpty(), "The following markers were not found: " + markerTests);
}
@Test
void workspaceHeaderCompletionsRespectExcludeGlobs() throws Exception {
var project = ResourcesPlugin.getWorkspace().getRoot().getProject(getClass().getName() + ".hdr" + System.nanoTime());
project.create(null);
project.open(null);
// Configure exclusion: exclude docs/generated/** from workspace header completions
Activator.getDefault().getPreferenceStore().setValue("markdown.suggest.paths.excludeGlobs", "docs/generated/**");
// Create markdown files with unique headers
// Ensure folders exist
var docsFolder = project.getFolder("docs");
if (!docsFolder.exists())
docsFolder.create(true, true, null);
var genFolder = docsFolder.getFolder("generated");
if (!genFolder.exists())
genFolder.create(true, true, null);
IFile excluded = project.getFile("docs/generated/excluded.md");
excluded.create("# Excluded Only\n".getBytes(StandardCharsets.UTF_8), true, false, null);
IFile included = project.getFile("docs/included.md");
included.create("# Included Only\n".getBytes(StandardCharsets.UTF_8), true, false, null);
// File where we'll trigger completions (double hash to respect default preference)
IFile index = project.getFile("index.md");
index.create("[](##)\n".getBytes(StandardCharsets.UTF_8), true, false, null);
var editor = (TextEditor) IDE.openEditor(PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(), index);
var display = editor.getSite().getShell().getDisplay();
IDocument document = editor.getDocumentProvider().getDocument(editor.getEditorInput());
// Ensure Markdown Language Server is started and connected
var markdownLS = new AtomicReference<LanguageServerWrapper>();
assertTrue(DisplayHelper.waitForCondition(display, 10_000, () -> {
markdownLS.set(LanguageServiceAccessor.getStartedWrappers(document, null, false).stream() //
.filter(w -> "org.eclipse.wildwebdeveloper.markdown".equals(w.serverDefinition.id)) //
.findFirst().orElse(null));
return markdownLS.get() != null //
&& markdownLS.get().isActive() //
&& markdownLS.get().isConnectedTo(LSPEclipseUtils.toUri(document));
}), "Markdown LS did not start");
// Trigger content assist at the end of '##'
int offset = document.get().indexOf("##") + 2;
var cap = new LSContentAssistProcessor();
assertTrue(DisplayHelper.waitForCondition(display, 15_000, () -> {
ICompletionProposal[] proposals = cap.computeCompletionProposals(Utils.getViewer(editor), offset);
if (proposals == null || proposals.length == 0)
return false;
boolean hasIncluded = Arrays.stream(proposals).anyMatch(p -> "#included-only".equals(p.getDisplayString()));
boolean hasExcluded = Arrays.stream(proposals).anyMatch(p -> "#excluded-only".equals(p.getDisplayString()));
return hasIncluded && !hasExcluded;
}), "Workspace header completions did not respect exclude globs");
}
}