Skip to content

Commit 1f3aaec

Browse files
committed
New test for Clipboard
Testing the Clipboard can be difficult because there are a lot of system issues that can interfere, but of particular concern are: 1. System clipboard managers which may take ownership of the clipboard at unexpected times. 2. Limitations as to when processes can access clipboard, such as on Wayland where only active window can access clipboard. 3. Different behaviour when copying within a single process than between processes. These tests aim to resolve these issues. For the system clipboard manager there are a lot of extra sleep calls to allow clipboard manager to complete operations before continuing tests. In addition, we run all the tests multiple times by default to ensure stability. For the process limitations, we carefully control when the shell is created because we often cannot get focus back when shell ends up in the background. See the openAndFocusShell and openAndFocusRemote methods. For the different behaviours, we spin up a simple Swing app in a new process (the "remote" in openAndFocusRemote above). This app can be directed, over RMI, to access the clipboard. This allows our test to place data on the clipboard and ensure that the remote app can read the data successfully. For now this test only covers basic text (and a little of RTF). Adding Image and other transfers is part of the future work as such functionality is added in GTK4 while working on #2126 For the changes to SwtTestUtil that we required: 1. isGTK4 moved from Test_org_eclipse_swt_widgets_Shell.java to the Utils 2. processEvents was limited to 20 readAndDispatch calls per second due to the ordering of the targetTimestamp check. This change allows full speed readAndDispatching. 3. getPath was refactored to allow better control of source and destination of files extracted. See extracting of class files for remote Swing app in startRemoteClipboardCommands method Part of #2126 Split out of #2538
1 parent 43f54cc commit 1f3aaec

File tree

7 files changed

+611
-21
lines changed

7 files changed

+611
-21
lines changed

tests/org.eclipse.swt.tests/JUnit Tests/org/eclipse/swt/tests/junit/AllNonBrowserTests.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@
4747
Test_org_eclipse_swt_accessibility_AccessibleTextEvent.class,
4848
Test_org_eclipse_swt_internal_SVGRasterizer.class,
4949
DPIUtilTests.class,
50-
JSVGRasterizerTest.class})
50+
JSVGRasterizerTest.class,
51+
Test_org_eclipse_swt_dnd_Clipboard.class,})
5152
public class AllNonBrowserTests {
5253
private static List<Error> leakedResources;
5354

tests/org.eclipse.swt.tests/JUnit Tests/org/eclipse/swt/tests/junit/SwtTestUtil.java

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ public class SwtTestUtil {
105105

106106
public final static boolean isX11 = isGTK
107107
&& "x11".equals(System.getProperty("org.eclipse.swt.internal.gdk.backend"));
108-
108+
public final static boolean isGTK4 = isGTK
109+
&& System.getProperty("org.eclipse.swt.internal.gtk.version", "").startsWith("4");
109110

110111
/**
111112
* The palette used by images. See {@link #getAllPixels(Image)} and {@link #createImage}
@@ -400,13 +401,16 @@ public static void processEvents(int timeoutMs, BooleanSupplier breakCondition)
400401
long targetTimestamp = System.currentTimeMillis() + timeoutMs;
401402
Display display = Display.getCurrent();
402403
while (!breakCondition.getAsBoolean()) {
403-
if (!display.readAndDispatch()) {
404-
if (System.currentTimeMillis() < targetTimestamp) {
405-
Thread.sleep(50);
406-
} else {
404+
while (display.readAndDispatch()) {
405+
if (System.currentTimeMillis() >= targetTimestamp) {
407406
return;
408407
}
409408
}
409+
if (System.currentTimeMillis() < targetTimestamp) {
410+
Thread.sleep(50);
411+
} else {
412+
return;
413+
}
410414
}
411415
}
412416

@@ -583,18 +587,24 @@ public static boolean hasPixelNotMatching(Image image, Color nonMatchingColor, R
583587
}
584588

585589
public static Path getPath(String fileName, TemporaryFolder tempFolder) {
586-
Path filePath = tempFolder.getRoot().toPath().resolve("image-resources").resolve(Path.of(fileName));
587-
if (!Files.isRegularFile(filePath)) {
590+
Path path = tempFolder.getRoot().toPath();
591+
Path filePath = path.resolve("image-resources").resolve(Path.of(fileName));
592+
return getPath(fileName, filePath);
593+
}
594+
595+
public static Path getPath(String sourceFilename, Path destinationPath) {
596+
if (!Files.isRegularFile(destinationPath)) {
588597
// Extract resource on the classpath to a temporary file to ensure it's
589598
// available as plain file, even if this bundle is packed as jar
590-
try (InputStream inStream = SwtTestUtil.class.getResourceAsStream(fileName)) {
591-
assertNotNull(inStream, "InputStream == null for file " + fileName);
592-
Files.createDirectories(filePath.getParent());
593-
Files.copy(inStream, filePath);
599+
try (InputStream inStream = SwtTestUtil.class.getResourceAsStream(sourceFilename)) {
600+
assertNotNull(inStream, "InputStream == null for file " + sourceFilename);
601+
Files.createDirectories(destinationPath.getParent());
602+
Files.copy(inStream, destinationPath);
594603
} catch (IOException e) {
595604
throw new IllegalArgumentException(e);
596605
}
597606
}
598-
return filePath;
607+
return destinationPath;
599608
}
609+
600610
}
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Kichwa Coders Canada, Inc.
3+
*
4+
* This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Public License 2.0
6+
* which accompanies this distribution, and is available at
7+
* https://www.eclipse.org/legal/epl-2.0/
8+
*
9+
* SPDX-License-Identifier: EPL-2.0
10+
*******************************************************************************/
11+
package org.eclipse.swt.tests.junit;
12+
13+
import static org.junit.jupiter.api.Assertions.assertEquals;
14+
import static org.junit.jupiter.api.Assertions.assertNotEquals;
15+
import static org.junit.jupiter.api.Assertions.assertNull;
16+
import static org.junit.jupiter.api.Assertions.assertTrue;
17+
18+
import java.io.BufferedReader;
19+
import java.io.InputStreamReader;
20+
import java.lang.ProcessBuilder.Redirect;
21+
import java.nio.file.Files;
22+
import java.nio.file.Path;
23+
import java.rmi.NotBoundException;
24+
import java.rmi.registry.LocateRegistry;
25+
import java.rmi.registry.Registry;
26+
import java.util.List;
27+
import java.util.concurrent.TimeUnit;
28+
import java.util.function.BooleanSupplier;
29+
30+
import org.eclipse.swt.dnd.Clipboard;
31+
import org.eclipse.swt.dnd.RTFTransfer;
32+
import org.eclipse.swt.dnd.TextTransfer;
33+
import org.eclipse.swt.dnd.Transfer;
34+
import org.eclipse.swt.widgets.Display;
35+
import org.eclipse.swt.widgets.Shell;
36+
import org.junit.jupiter.api.AfterEach;
37+
import org.junit.jupiter.api.BeforeEach;
38+
import org.junit.jupiter.api.RepeatedTest;
39+
import org.junit.jupiter.api.Test;
40+
import org.junit.jupiter.api.io.TempDir;
41+
42+
import clipboard.ClipboardCommands;
43+
44+
/**
45+
* Automated Test Suite for class org.eclipse.swt.dnd.Clipboard
46+
*
47+
* @see org.eclipse.swt.dnd.Clipboard
48+
* @see Test_org_eclipse_swt_custom_StyledText StyledText tests as it also does
49+
* some clipboard tests
50+
*/
51+
public class Test_org_eclipse_swt_dnd_Clipboard {
52+
53+
private static final int REPEAT_COUNT = 3;
54+
@TempDir
55+
static Path tempFolder;
56+
static int uniqueId = 1;
57+
private Display display;
58+
private Shell shell;
59+
private Clipboard clipboard;
60+
private TextTransfer textTransfer;
61+
private RTFTransfer rtfTransfer;
62+
private ClipboardCommands remote;
63+
private Process remoteClipboardProcess;
64+
65+
@BeforeEach
66+
public void setUp() {
67+
display = Display.getCurrent();
68+
if (display == null) {
69+
display = Display.getDefault();
70+
}
71+
72+
clipboard = new Clipboard(display);
73+
textTransfer = TextTransfer.getInstance();
74+
rtfTransfer = RTFTransfer.getInstance();
75+
}
76+
77+
/**
78+
* TODO remove all uses of sleep and change them to processEvents with the
79+
* suitable conditional, or entirely remove them
80+
*/
81+
private void sleep() throws InterruptedException {
82+
SwtTestUtil.processEvents(100, null);
83+
}
84+
85+
/**
86+
* Note: Wayland backend does not allow access to system clipboard from
87+
* non-focussed windows. So we have to create/open and focus a window here so
88+
* that clipboard operations work.
89+
*/
90+
private void openAndFocusShell() throws InterruptedException {
91+
shell = new Shell(display);
92+
shell.open();
93+
shell.setFocus();
94+
sleep();
95+
}
96+
97+
/**
98+
* Note: Wayland backend does not allow access to system clipboard from
99+
* non-focussed windows. So we have to open and focus remote here so that
100+
* clipboard operations work.
101+
*/
102+
private void openAndFocusRemote() throws Exception {
103+
startRemoteClipboardCommands();
104+
remote.setFocus();
105+
remote.waitUntilReady();
106+
sleep();
107+
}
108+
109+
@AfterEach
110+
public void tearDown() throws Exception {
111+
sleep();
112+
try {
113+
stopRemoteClipboardCommands();
114+
} finally {
115+
if (clipboard != null) {
116+
clipboard.dispose();
117+
}
118+
if (shell != null) {
119+
shell.dispose();
120+
}
121+
SwtTestUtil.processEvents();
122+
}
123+
}
124+
125+
private void startRemoteClipboardCommands() throws Exception {
126+
// TODO: Can I get the jar and run it without extracting it?
127+
// TODO: Can I get all the files without having to list them?
128+
List.of( //
129+
"ClipboardTest", //
130+
"ClipboardCommands", //
131+
"ClipboardCommandsImpl", //
132+
"ClipboardTest$LocalHostOnlySocketFactory" //
133+
).forEach((f) -> {
134+
// extract the files and put them in the temp directory
135+
SwtTestUtil.getPath("/clipboard/" + f + ".class", tempFolder.resolve("clipboard/" + f + ".class"));
136+
});
137+
138+
String javaHome = System.getProperty("java.home");
139+
String javaExe = javaHome + "/bin/java";
140+
assertTrue(Files.exists(Path.of(javaExe)));
141+
142+
ProcessBuilder pb = new ProcessBuilder(javaExe, "clipboard.ClipboardTest").directory(tempFolder.toFile());
143+
pb.inheritIO();
144+
pb.redirectOutput(Redirect.PIPE);
145+
remoteClipboardProcess = pb.start();
146+
147+
// Read server output to find the port
148+
BufferedReader reader = new BufferedReader(new InputStreamReader(remoteClipboardProcess.getInputStream()));
149+
int port = 0;
150+
String line;
151+
// TODO: add a timeout here
152+
while ((line = reader.readLine()) != null) {
153+
if (line.startsWith(ClipboardCommands.PORT_MESSAGE)) {
154+
String[] parts = line.split(":");
155+
port = Integer.parseInt(parts[1].trim());
156+
break;
157+
}
158+
}
159+
assertNotEquals(0, port);
160+
Registry reg = LocateRegistry.getRegistry("127.0.0.1", port);
161+
long stopTime = System.currentTimeMillis() + 1000;
162+
do {
163+
try {
164+
remote = (ClipboardCommands) reg.lookup(ClipboardCommands.ID);
165+
break;
166+
} catch (NotBoundException e) {
167+
// try again because the remote app probably hasn't bound yet
168+
}
169+
} while (System.currentTimeMillis() < stopTime);
170+
171+
// Run a no-op on the Swing event loop so that we know it is idle
172+
// and we can continue startup
173+
remote.waitUntilReady();
174+
}
175+
176+
private void stopRemoteClipboardCommands() throws Exception {
177+
try {
178+
if (remote != null) {
179+
remote.stop();
180+
remote = null;
181+
}
182+
} finally {
183+
if (remoteClipboardProcess != null) {
184+
try {
185+
remoteClipboardProcess.waitFor(1, TimeUnit.SECONDS);
186+
} finally {
187+
remoteClipboardProcess.destroyForcibly();
188+
remoteClipboardProcess = null;
189+
}
190+
}
191+
}
192+
}
193+
194+
/**
195+
* Make sure to always copy/paste unique strings - this ensures that tests run
196+
* under {@link RepeatedTest}s don't false pass because of clipboard value on
197+
* previous iteration.
198+
*/
199+
private String getUniqueTestString() {
200+
return "Hello World " + uniqueId++;
201+
}
202+
203+
/**
204+
* Test that the remote application clipboard works
205+
*/
206+
@Test
207+
public void test_Remote() throws Exception {
208+
openAndFocusRemote();
209+
String helloWorld = getUniqueTestString();
210+
remote.setContents(helloWorld);
211+
assertEquals(helloWorld, remote.getStringContents());
212+
}
213+
214+
/**
215+
* This tests set + get on local clipboard. Remote clipboard can have different
216+
* behaviours and has additional tests.
217+
*/
218+
@RepeatedTest(value = REPEAT_COUNT)
219+
public void test_LocalClipboard() throws Exception {
220+
openAndFocusShell();
221+
222+
String helloWorld = getUniqueTestString();
223+
clipboard.setContents(new Object[] { helloWorld }, new Transfer[] { textTransfer });
224+
assertEquals(helloWorld, clipboard.getContents(textTransfer));
225+
assertNull(clipboard.getContents(rtfTransfer));
226+
227+
helloWorld = getUniqueTestString();
228+
String helloWorldRtf = "{\\rtf1\\b\\i " + helloWorld + "}";
229+
clipboard.setContents(new Object[] { helloWorld, helloWorldRtf }, new Transfer[] { textTransfer, rtfTransfer });
230+
assertEquals(helloWorld, clipboard.getContents(textTransfer));
231+
assertEquals(helloWorldRtf, clipboard.getContents(rtfTransfer));
232+
233+
helloWorld = getUniqueTestString();
234+
helloWorldRtf = "{\\rtf1\\b\\i " + helloWorld + "}";
235+
clipboard.setContents(new Object[] { helloWorldRtf }, new Transfer[] { rtfTransfer });
236+
assertNull(clipboard.getContents(textTransfer));
237+
assertEquals(helloWorldRtf, clipboard.getContents(rtfTransfer));
238+
}
239+
240+
@RepeatedTest(value = REPEAT_COUNT)
241+
public void test_setContents() throws Exception {
242+
try {
243+
openAndFocusShell();
244+
String helloWorld = getUniqueTestString();
245+
246+
clipboard.setContents(new Object[] { helloWorld }, new Transfer[] { textTransfer });
247+
sleep();
248+
249+
openAndFocusRemote();
250+
SwtTestUtil.processEvents(1000, () -> helloWorld.equals(runOperationInThread(remote::getStringContents)));
251+
String result = runOperationInThread(remote::getStringContents);
252+
assertEquals(helloWorld, result);
253+
} catch (Exception | AssertionError e) {
254+
if (SwtTestUtil.isGTK4 && !SwtTestUtil.isX11) {
255+
// TODO make the code + test stable
256+
throw new RuntimeException(
257+
"This test is really unstable on wayland backend, at least with Ubuntu 25.04", e);
258+
}
259+
throw e;
260+
}
261+
}
262+
263+
@RepeatedTest(value = REPEAT_COUNT)
264+
public void test_getContents() throws Exception {
265+
openAndFocusRemote();
266+
String helloWorld = getUniqueTestString();
267+
remote.setContents(helloWorld);
268+
269+
openAndFocusShell();
270+
SwtTestUtil.processEvents(1000, () -> {
271+
return helloWorld.equals(clipboard.getContents(textTransfer));
272+
});
273+
assertEquals(helloWorld, clipboard.getContents(textTransfer));
274+
}
275+
276+
@FunctionalInterface
277+
public interface ExceptionalSupplier<T> {
278+
T get() throws Exception;
279+
}
280+
281+
/**
282+
* When running some operations, such as requesting remote process read the
283+
* clipboard, we need to have the event queue processing otherwise the remote
284+
* won't be able to read our clipboard contribution.
285+
*
286+
* This method starts the supplier in a new thread and runs the event loop until
287+
* the thread completes.
288+
*
289+
* @throws InterruptedException
290+
*/
291+
private <T> T runOperationInThread(ExceptionalSupplier<T> supplier) throws RuntimeException {
292+
Object[] supplierValue = new Object[1];
293+
Exception[] supplierException = new Exception[1];
294+
Runnable task = () -> {
295+
try {
296+
supplierValue[0] = supplier.get();
297+
} catch (Exception e) {
298+
supplierValue[0] = null;
299+
supplierException[0] = e;
300+
}
301+
};
302+
Thread thread = new Thread(task, this.getClass().getName() + ".runOperationInThread");
303+
thread.setDaemon(true);
304+
thread.start();
305+
BooleanSupplier done = () -> !thread.isAlive();
306+
try {
307+
SwtTestUtil.processEvents(2000, done);
308+
} catch (InterruptedException e) {
309+
throw new RuntimeException("Failed while running thread", e);
310+
}
311+
assertTrue(done.getAsBoolean());
312+
if (supplierException[0] != null) {
313+
throw new RuntimeException("Failed while running thread", supplierException[0]);
314+
}
315+
@SuppressWarnings("unchecked")
316+
T result = (T) supplierValue[0];
317+
return result;
318+
}
319+
}

0 commit comments

Comments
 (0)