From 0862412eb4d32af9e8b9ce05e505f1d67daedf3e Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 1 Jun 2015 13:22:20 -0700 Subject: [PATCH] Add filesystem watcher support Add a filesystem watcher that can be used to inform listeners of changes (add, delete, modify) to files in specific source folders. The implementation uses a background thread rather than the WatchService API to remain compatible with Java 6 and because WatchService is slow on OSX. See gh-3084 --- .../developertools/filewatch/ChangedFile.java | 128 +++++++++ .../filewatch/ChangedFiles.java | 89 ++++++ .../filewatch/FileChangeListener.java | 36 +++ .../filewatch/FileSnapshot.java | 84 ++++++ .../filewatch/FileSystemWatcher.java | 211 +++++++++++++++ .../filewatch/FolderSnapshot.java | 141 ++++++++++ .../filewatch/package-info.java | 21 ++ .../filewatch/ChangedFileTests.java | 87 ++++++ .../filewatch/FileSnapshotTests.java | 112 ++++++++ .../filewatch/FileSystemWatcherTests.java | 255 ++++++++++++++++++ .../filewatch/FolderSnapshotTests.java | 156 +++++++++++ 11 files changed, 1320 insertions(+) create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFile.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFiles.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileChangeListener.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSnapshot.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSystemWatcher.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FolderSnapshot.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/package-info.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/ChangedFileTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSnapshotTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSystemWatcherTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FolderSnapshotTests.java diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFile.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFile.java new file mode 100644 index 00000000000..7c7e71e5309 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFile.java @@ -0,0 +1,128 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; + +import org.springframework.util.Assert; + +/** + * A single file that has changed. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ChangedFiles + */ +public final class ChangedFile { + + private final File sourceFolder; + + private final File file; + + private final Type type; + + /** + * Create a new {@link ChangedFile} instance. + * @param sourceFolder the source folder + * @param file the file + * @param type the type of change + */ + public ChangedFile(File sourceFolder, File file, Type type) { + Assert.notNull(sourceFolder, "SourceFolder must not be null"); + Assert.notNull(file, "File must not be null"); + Assert.notNull(type, "Type must not be null"); + this.sourceFolder = sourceFolder; + this.file = file; + this.type = type; + } + + /** + * Return the file that was changed. + * @return the file + */ + public File getFile() { + return this.file; + } + + /** + * Return the type of change. + * @return the type of change + */ + public Type getType() { + return this.type; + } + + /** + * Return the name of the file relative to the source folder. + * @return the relative name + */ + public String getRelativeName() { + String folderName = this.sourceFolder.getAbsoluteFile().getPath(); + String fileName = this.file.getAbsoluteFile().getPath(); + Assert.state(fileName.startsWith(folderName), "The file " + fileName + + " is not contained in the source folder " + folderName); + return fileName.substring(folderName.length() + 1); + } + + @Override + public int hashCode() { + return this.file.hashCode() * 31 + this.type.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null) { + return false; + } + if (obj instanceof ChangedFile) { + ChangedFile other = (ChangedFile) obj; + return this.file.equals(other.file) && this.type.equals(other.type); + } + return super.equals(obj); + } + + @Override + public String toString() { + return this.file + " (" + this.type + ")"; + } + + /** + * Change types. + */ + public static enum Type { + + /** + * A new file has been added. + */ + ADD, + + /** + * An existing file has been modified. + */ + MODIFY, + + /** + * An existing file has been deleted. + */ + DELETE + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFiles.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFiles.java new file mode 100644 index 00000000000..87b2dfe442e --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFiles.java @@ -0,0 +1,89 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.util.Collections; +import java.util.Iterator; +import java.util.Set; + +/** + * A collections of files from a specific source folder that have changed. + * + * @author Phillip Webb + * @since 1.3.0 + * @see FileChangeListener + * @see ChangedFiles + */ +public final class ChangedFiles implements Iterable { + + private final File sourceFolder; + + private final Set files; + + public ChangedFiles(File sourceFolder, Set files) { + this.sourceFolder = sourceFolder; + this.files = Collections.unmodifiableSet(files); + } + + /** + * The source folder being watched. + * @return the source folder + */ + public File getSourceFolder() { + return this.sourceFolder; + } + + @Override + public Iterator iterator() { + return getFiles().iterator(); + } + + /** + * The files that have been changed. + * @return the changed files + */ + public Set getFiles() { + return this.files; + } + + @Override + public int hashCode() { + return this.files.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (obj instanceof ChangedFiles) { + ChangedFiles other = (ChangedFiles) obj; + return this.sourceFolder.equals(other.sourceFolder) + && this.files.equals(other.files); + } + return super.equals(obj); + } + + @Override + public String toString() { + return this.sourceFolder + " " + this.files; + } +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileChangeListener.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileChangeListener.java new file mode 100644 index 00000000000..11960289470 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileChangeListener.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.util.Set; + +/** + * Callback interface when file changes are detected. + * + * @author Andy Clement + * @author Phillip Webb + * @since 1.3.0 + */ +public interface FileChangeListener { + + /** + * Called when files have been changed. + * @param changeSet a set of the {@link ChangedFiles} + */ + void onChange(Set changeSet); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSnapshot.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSnapshot.java new file mode 100644 index 00000000000..567616c3375 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSnapshot.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; + +import org.springframework.util.Assert; + +/** + * A snapshot of a File at a given point in time. + * + * @author Phillip Webb + */ +class FileSnapshot { + + private final File file; + + private final boolean exists; + + private final long length; + + private final long lastModified; + + public FileSnapshot(File file) { + Assert.notNull(file, "File must not be null"); + Assert.isTrue(file.isFile() || !file.exists(), "File must not be a folder"); + this.file = file; + this.exists = file.exists(); + this.length = file.length(); + this.lastModified = file.lastModified(); + } + + public File getFile() { + return this.file; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (obj instanceof FileSnapshot) { + FileSnapshot other = (FileSnapshot) obj; + boolean equals = this.file.equals(other.file); + equals &= this.exists == other.exists; + equals &= this.length == other.length; + equals &= this.lastModified == other.lastModified; + return equals; + } + return super.equals(obj); + } + + @Override + public int hashCode() { + int hashCode = this.file.hashCode(); + hashCode = 31 * hashCode + (this.exists ? 1231 : 1237); + hashCode = 31 * hashCode + (int) (this.length ^ (this.length >>> 32)); + hashCode = 31 * hashCode + (int) (this.lastModified ^ (this.lastModified >>> 32)); + return hashCode; + } + + @Override + public String toString() { + return this.file.toString(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSystemWatcher.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSystemWatcher.java new file mode 100644 index 00000000000..4ee5c31cad6 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSystemWatcher.java @@ -0,0 +1,211 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.util.Assert; + +/** + * Watches specific folders for file changes. + * + * @author Andy Clement + * @author Phillip Webb + * @see FileChangeListener + * @since 1.3.0 + */ +public class FileSystemWatcher { + + private static final long DEFAULT_IDLE_TIME = 400; + + private static final long DEFAULT_QUIET_TIME = 200; + + private List listeners = new ArrayList(); + + private final boolean daemon; + + private final long idleTime; + + private final long quietTime; + + private Thread watchThread; + + private AtomicInteger remainingScans = new AtomicInteger(-1); + + private Map folders = new LinkedHashMap(); + + /** + * Create a new {@link FileSystemWatcher} instance. + */ + public FileSystemWatcher() { + this(true, DEFAULT_IDLE_TIME, DEFAULT_QUIET_TIME); + } + + /** + * Create a new {@link FileSystemWatcher} instance. + * @param daemon if a daemon thread used to monitor changes + * @param idleTime the amount of time to wait between checking for changes + * @param quietTime the amount of time required after a change has been detected to + * ensure that updates have completed + */ + public FileSystemWatcher(boolean daemon, long idleTime, long quietTime) { + this.daemon = daemon; + this.idleTime = idleTime; + this.quietTime = quietTime; + } + + /** + * Add listener for file change events. Cannot be called after the watcher has been + * {@link #start() started}. + * @param fileChangeListener the listener to add + */ + public synchronized void addListener(FileChangeListener fileChangeListener) { + Assert.notNull(fileChangeListener, "FileChangeListener must not be null"); + checkNotStarted(); + this.listeners.add(fileChangeListener); + } + + /** + * Add a source folder to monitor. Cannot be called after the watcher has been + * {@link #start() started}. + * @param folder the folder to monitor + */ + public synchronized void addSourceFolder(File folder) { + Assert.notNull(folder, "Folder must not be null"); + Assert.isTrue(folder.isDirectory(), "Folder must not be a file"); + checkNotStarted(); + this.folders.put(folder, null); + } + + private void checkNotStarted() { + Assert.state(this.watchThread == null, "FileSystemWatcher already started"); + } + + /** + * Start monitoring the source folder for changes. + */ + public synchronized void start() { + saveInitalSnapshots(); + if (this.watchThread == null) { + this.watchThread = new Thread() { + @Override + public void run() { + int remainingScans = FileSystemWatcher.this.remainingScans.get(); + while (remainingScans > 0 || remainingScans == -1) { + try { + if (remainingScans > 0) { + FileSystemWatcher.this.remainingScans.decrementAndGet(); + } + scan(); + remainingScans = FileSystemWatcher.this.remainingScans.get(); + } + catch (InterruptedException ex) { + } + } + }; + }; + this.watchThread.setName("File Watcher"); + this.watchThread.setDaemon(this.daemon); + this.remainingScans = new AtomicInteger(-1); + this.watchThread.start(); + } + } + + private void saveInitalSnapshots() { + for (File folder : this.folders.keySet()) { + this.folders.put(folder, new FolderSnapshot(folder)); + } + } + + private void scan() throws InterruptedException { + Thread.sleep(this.idleTime - this.quietTime); + Set previous; + Set current = new HashSet(this.folders.values()); + do { + previous = current; + current = getCurrentSnapshots(); + Thread.sleep(this.quietTime); + } + while (!previous.equals(current)); + updateSnapshots(current); + } + + private Set getCurrentSnapshots() { + Set snapshots = new LinkedHashSet(); + for (File folder : this.folders.keySet()) { + snapshots.add(new FolderSnapshot(folder)); + } + return snapshots; + } + + private void updateSnapshots(Set snapshots) { + Map updated = new LinkedHashMap(); + Set changeSet = new LinkedHashSet(); + for (FolderSnapshot snapshot : snapshots) { + FolderSnapshot previous = this.folders.get(snapshot.getFolder()); + updated.put(snapshot.getFolder(), snapshot); + ChangedFiles changedFiles = previous.getChangedFiles(snapshot); + if (!changedFiles.getFiles().isEmpty()) { + changeSet.add(changedFiles); + } + } + if (!changeSet.isEmpty()) { + fireListeners(Collections.unmodifiableSet(changeSet)); + } + this.folders = updated; + } + + private void fireListeners(Set changeSet) { + for (FileChangeListener listener : this.listeners) { + listener.onChange(changeSet); + } + } + + /** + * Stop monitoring the source folders. + */ + public synchronized void stop() { + stopAfter(0); + } + + /** + * Stop monitoring the source folders. + * @param remainingScans the number of scans remaming + */ + synchronized void stopAfter(int remainingScans) { + Thread thread = this.watchThread; + if (thread != null) { + this.remainingScans.set(remainingScans); + try { + thread.join(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + this.watchThread = null; + } + } +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FolderSnapshot.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FolderSnapshot.java new file mode 100644 index 00000000000..87534b919a4 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FolderSnapshot.java @@ -0,0 +1,141 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.boot.developertools.filewatch.ChangedFile.Type; +import org.springframework.util.Assert; + +/** + * A snapshot of a folder at a given point in time. + * + * @author Phillip Webb + */ +class FolderSnapshot { + + private static final Set DOT_FOLDERS = Collections + .unmodifiableSet(new HashSet(Arrays.asList(".", ".."))); + + private final File folder; + + private final Date time; + + private Set files; + + /** + * Create a new {@link FolderSnapshot} for the given folder. + * @param folder the source folder + */ + public FolderSnapshot(File folder) { + Assert.notNull(folder, "Folder must not be null"); + Assert.isTrue(folder.isDirectory(), "Folder must not be a file"); + this.folder = folder; + this.time = new Date(); + Set files = new LinkedHashSet(); + collectFiles(folder, files); + this.files = Collections.unmodifiableSet(files); + } + + private void collectFiles(File source, Set result) { + File[] children = source.listFiles(); + if (children != null) { + for (File child : children) { + if (child.isDirectory() && !DOT_FOLDERS.contains(child.getName())) { + collectFiles(child, result); + } + else if (child.isFile()) { + result.add(new FileSnapshot(child)); + } + } + } + } + + public ChangedFiles getChangedFiles(FolderSnapshot snapshot) { + Assert.notNull(snapshot, "Snapshot must not be null"); + File folder = this.folder; + Assert.isTrue(snapshot.folder.equals(folder), "Snapshot source folder must be '" + + folder + "'"); + Set changes = new LinkedHashSet(); + Map previousFiles = getFilesMap(); + for (FileSnapshot currentFile : snapshot.files) { + FileSnapshot previousFile = previousFiles.remove(currentFile.getFile()); + if (previousFile == null) { + changes.add(new ChangedFile(folder, currentFile.getFile(), Type.ADD)); + } + else if (!previousFile.equals(currentFile)) { + changes.add(new ChangedFile(folder, currentFile.getFile(), Type.MODIFY)); + } + } + for (FileSnapshot previousFile : previousFiles.values()) { + changes.add(new ChangedFile(folder, previousFile.getFile(), Type.DELETE)); + } + return new ChangedFiles(folder, changes); + } + + private Map getFilesMap() { + Map files = new LinkedHashMap(); + for (FileSnapshot file : this.files) { + files.put(file.getFile(), file); + } + return files; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (obj instanceof FolderSnapshot) { + FolderSnapshot other = (FolderSnapshot) obj; + return this.folder.equals(other.folder) && this.files.equals(other.files); + } + return super.equals(obj); + } + + @Override + public int hashCode() { + int hashCode = this.folder.hashCode(); + hashCode = 31 * hashCode + this.files.hashCode(); + return hashCode; + } + + /** + * Return the source folder of this snapshot. + * @return the source folder + */ + public File getFolder() { + return this.folder; + } + + @Override + public String toString() { + return this.folder + " snaphost at " + this.time; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/package-info.java new file mode 100644 index 00000000000..1bc251d01a4 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Class to watch the local filesystem for changes. + */ +package org.springframework.boot.developertools.filewatch; + diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/ChangedFileTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/ChangedFileTests.java new file mode 100644 index 00000000000..70d3a732982 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/ChangedFileTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.springframework.boot.developertools.filewatch.ChangedFile.Type; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link ChangedFile}. + * + * @author Phillip Webb + */ +public class ChangedFileTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + @Test + public void sourceFolderMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("SourceFolder must not be null"); + new ChangedFile(null, this.temp.newFile(), Type.ADD); + } + + @Test + public void fileMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("File must not be null"); + new ChangedFile(this.temp.newFolder(), null, Type.ADD); + } + + @Test + public void typeMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Type must not be null"); + new ChangedFile(this.temp.newFile(), this.temp.newFolder(), null); + } + + @Test + public void getFile() throws Exception { + File file = this.temp.newFile(); + ChangedFile changedFile = new ChangedFile(this.temp.newFolder(), file, Type.ADD); + assertThat(changedFile.getFile(), equalTo(file)); + } + + @Test + public void getType() throws Exception { + ChangedFile changedFile = new ChangedFile(this.temp.newFolder(), + this.temp.newFile(), Type.DELETE); + assertThat(changedFile.getType(), equalTo(Type.DELETE)); + } + + @Test + public void getRelativeName() throws Exception { + File folder = this.temp.newFolder(); + File subFolder = new File(folder, "A"); + File file = new File(subFolder, "B.txt"); + ChangedFile changedFile = new ChangedFile(folder, file, Type.ADD); + assertThat(changedFile.getRelativeName(), equalTo("A/B.txt")); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSnapshotTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSnapshotTests.java new file mode 100644 index 00000000000..ae47cd5eafa --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSnapshotTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.io.IOException; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.springframework.util.FileCopyUtils; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link FileSnapshot}. + * + * @author Phillip Webb + */ +public class FileSnapshotTests { + + private static final long TWO_MINS = TimeUnit.MINUTES.toMillis(2); + + private static final long MODIFIED = new Date().getTime() + - TimeUnit.DAYS.toMillis(10); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void fileMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("File must not be null"); + new FileSnapshot(null); + } + + @Test + public void fileMustNotBeAFolder() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("File must not be a folder"); + new FileSnapshot(this.temporaryFolder.newFolder()); + } + + @Test + public void equalsIfTheSame() throws Exception { + File file = createNewFile("abc", MODIFIED); + File fileCopy = new File(file, "x").getParentFile(); + FileSnapshot snapshot1 = new FileSnapshot(file); + FileSnapshot snapshot2 = new FileSnapshot(fileCopy); + assertThat(snapshot1, equalTo(snapshot2)); + assertThat(snapshot1.hashCode(), equalTo(snapshot2.hashCode())); + } + + @Test + public void notEqualsIfDeleted() throws Exception { + File file = createNewFile("abc", MODIFIED); + FileSnapshot snapshot1 = new FileSnapshot(file); + file.delete(); + assertThat(snapshot1, not(equalTo(new FileSnapshot(file)))); + } + + @Test + public void notEqualsIfLengthChanges() throws Exception { + File file = createNewFile("abc", MODIFIED); + FileSnapshot snapshot1 = new FileSnapshot(file); + setupFile(file, "abcd", MODIFIED); + assertThat(snapshot1, not(equalTo(new FileSnapshot(file)))); + } + + @Test + public void notEqualsIfLastModifiedChanges() throws Exception { + File file = createNewFile("abc", MODIFIED); + FileSnapshot snapshot1 = new FileSnapshot(file); + setupFile(file, "abc", MODIFIED + TWO_MINS); + assertThat(snapshot1, not(equalTo(new FileSnapshot(file)))); + } + + private File createNewFile(String content, long lastModified) throws IOException { + File file = this.temporaryFolder.newFile(); + setupFile(file, content, lastModified); + return file; + } + + private void setupFile(File file, String content, long lastModified) + throws IOException { + FileCopyUtils.copy(content.getBytes(), file); + file.setLastModified(lastModified); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSystemWatcherTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSystemWatcherTests.java new file mode 100644 index 00000000000..42b005469ba --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSystemWatcherTests.java @@ -0,0 +1,255 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.springframework.boot.developertools.filewatch.ChangedFile.Type; +import org.springframework.util.FileCopyUtils; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link FileSystemWatcher}. + * + * @author Phillip Webb + */ +public class FileSystemWatcherTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private FileSystemWatcher watcher; + + private List> changes = new ArrayList>(); + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + @Before + public void setup() throws Exception { + setupWatcher(20, 10); + } + + @Test + public void listenerMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("FileChangeListener must not be null"); + this.watcher.addListener(null); + } + + @Test + public void cannotAddListenerToStartedListener() throws Exception { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("FileSystemWatcher already started"); + this.watcher.start(); + this.watcher.addListener(mock(FileChangeListener.class)); + } + + @Test + public void sourceFolderMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Folder must not be null"); + this.watcher.addSourceFolder(null); + } + + @Test + public void cannotAddSourceFolderToStartedListener() throws Exception { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("FileSystemWatcher already started"); + this.watcher.start(); + this.watcher.addSourceFolder(this.temp.newFolder()); + } + + @Test + public void addFile() throws Exception { + File folder = startWithNewFolder(); + File file = touch(new File(folder, "test.txt")); + this.watcher.stopAfter(1); + ChangedFiles changedFiles = getSingleChangedFiles(); + ChangedFile expected = new ChangedFile(folder, file, Type.ADD); + assertThat(changedFiles.getFiles(), contains(expected)); + } + + @Test + public void addNestedFile() throws Exception { + File folder = startWithNewFolder(); + File file = touch(new File(new File(folder, "sub"), "text.txt")); + this.watcher.stopAfter(1); + ChangedFiles changedFiles = getSingleChangedFiles(); + ChangedFile expected = new ChangedFile(folder, file, Type.ADD); + assertThat(changedFiles.getFiles(), contains(expected)); + } + + @Test + public void waitsForIdleTime() throws Exception { + this.changes.clear(); + setupWatcher(100, 0); + File folder = startWithNewFolder(); + touch(new File(folder, "test1.txt")); + Thread.sleep(200); + touch(new File(folder, "test2.txt")); + this.watcher.stopAfter(1); + assertThat(this.changes.size(), equalTo(2)); + } + + @Test + public void waitsForQuietTime() throws Exception { + setupWatcher(300, 200); + File folder = startWithNewFolder(); + for (int i = 0; i < 10; i++) { + touch(new File(folder, i + "test.txt")); + Thread.sleep(100); + } + this.watcher.stopAfter(1); + ChangedFiles changedFiles = getSingleChangedFiles(); + assertThat(changedFiles.getFiles().size(), equalTo(10)); + } + + @Test + public void withExistingFiles() throws Exception { + File folder = this.temp.newFolder(); + touch(new File(folder, "test.txt")); + this.watcher.addSourceFolder(folder); + this.watcher.start(); + File file = touch(new File(folder, "test2.txt")); + this.watcher.stopAfter(1); + ChangedFiles changedFiles = getSingleChangedFiles(); + ChangedFile expected = new ChangedFile(folder, file, Type.ADD); + assertThat(changedFiles.getFiles(), contains(expected)); + } + + @Test + public void multipleSources() throws Exception { + File folder1 = this.temp.newFolder(); + File folder2 = this.temp.newFolder(); + this.watcher.addSourceFolder(folder1); + this.watcher.addSourceFolder(folder2); + this.watcher.start(); + File file1 = touch(new File(folder1, "test.txt")); + File file2 = touch(new File(folder2, "test.txt")); + this.watcher.stopAfter(1); + Set change = getSingleOnChange(); + assertThat(change.size(), equalTo(2)); + for (ChangedFiles changedFiles : change) { + if (changedFiles.getSourceFolder().equals(folder1)) { + ChangedFile file = new ChangedFile(folder1, file1, Type.ADD); + assertEquals(new HashSet(Arrays.asList(file)), + changedFiles.getFiles()); + } + else { + ChangedFile file = new ChangedFile(folder2, file2, Type.ADD); + assertEquals(new HashSet(Arrays.asList(file)), + changedFiles.getFiles()); + } + } + } + + @Test + public void multipleListeners() throws Exception { + File folder = this.temp.newFolder(); + final Set listener2Changes = new LinkedHashSet(); + this.watcher.addSourceFolder(folder); + this.watcher.addListener(new FileChangeListener() { + @Override + public void onChange(Set changeSet) { + listener2Changes.addAll(changeSet); + } + }); + this.watcher.start(); + File file = touch(new File(folder, "test.txt")); + this.watcher.stopAfter(1); + ChangedFiles changedFiles = getSingleChangedFiles(); + ChangedFile expected = new ChangedFile(folder, file, Type.ADD); + assertThat(changedFiles.getFiles(), contains(expected)); + assertEquals(this.changes.get(0), listener2Changes); + } + + @Test + public void modifyDeleteAndAdd() throws Exception { + File folder = this.temp.newFolder(); + File modify = touch(new File(folder, "modify.txt")); + File delete = touch(new File(folder, "delete.txt")); + this.watcher.addSourceFolder(folder); + this.watcher.start(); + FileCopyUtils.copy("abc".getBytes(), modify); + delete.delete(); + File add = touch(new File(folder, "add.txt")); + this.watcher.stopAfter(1); + ChangedFiles changedFiles = getSingleChangedFiles(); + Set actual = changedFiles.getFiles(); + Set expected = new HashSet(); + expected.add(new ChangedFile(folder, modify, Type.MODIFY)); + expected.add(new ChangedFile(folder, delete, Type.DELETE)); + expected.add(new ChangedFile(folder, add, Type.ADD)); + assertEquals(expected, actual); + } + + private void setupWatcher(long idleTime, long quietTime) { + this.watcher = new FileSystemWatcher(false, idleTime, quietTime); + this.watcher.addListener(new FileChangeListener() { + @Override + public void onChange(Set changeSet) { + FileSystemWatcherTests.this.changes.add(changeSet); + } + }); + } + + private File startWithNewFolder() throws IOException { + File folder = this.temp.newFolder(); + this.watcher.addSourceFolder(folder); + this.watcher.start(); + return folder; + } + + private ChangedFiles getSingleChangedFiles() { + Set singleChange = getSingleOnChange(); + assertThat(singleChange.size(), equalTo(1)); + return singleChange.iterator().next(); + } + + private Set getSingleOnChange() { + assertThat(this.changes.size(), equalTo(1)); + return this.changes.get(0); + } + + private File touch(File file) throws FileNotFoundException, IOException { + file.getParentFile().mkdirs(); + FileOutputStream fileOutputStream = new FileOutputStream(file); + fileOutputStream.close(); + return file; + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FolderSnapshotTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FolderSnapshotTests.java new file mode 100644 index 00000000000..2d9875f6beb --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FolderSnapshotTests.java @@ -0,0 +1,156 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.io.IOException; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.springframework.boot.developertools.filewatch.ChangedFile.Type; +import org.springframework.util.FileCopyUtils; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link FolderSnapshot}. + * + * @author Phillip Webb + */ +public class FolderSnapshotTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private File folder; + + private FolderSnapshot initialSnapshot; + + @Before + public void setup() throws Exception { + this.folder = createTestFolderStructure(); + this.initialSnapshot = new FolderSnapshot(this.folder); + } + + @Test + public void folderMustNotBeNull() { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Folder must not be null"); + new FolderSnapshot(null); + } + + @Test + public void folderMustNotBeFile() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Folder must not be a file"); + new FolderSnapshot(this.temporaryFolder.newFile()); + } + + @Test + public void equalsWhenNothingHasChanged() throws Exception { + FolderSnapshot updatedSnapshot = new FolderSnapshot(this.folder); + assertThat(this.initialSnapshot, equalTo(updatedSnapshot)); + assertThat(this.initialSnapshot.hashCode(), equalTo(updatedSnapshot.hashCode())); + } + + @Test + public void notEqualsWhenAFileIsAdded() throws Exception { + new File(new File(this.folder, "folder1"), "newfile").createNewFile(); + FolderSnapshot updatedSnapshot = new FolderSnapshot(this.folder); + assertThat(this.initialSnapshot, not(equalTo(updatedSnapshot))); + } + + @Test + public void notEqualsWhenAFileIsDeleted() throws Exception { + new File(new File(this.folder, "folder1"), "file1").delete(); + FolderSnapshot updatedSnapshot = new FolderSnapshot(this.folder); + assertThat(this.initialSnapshot, not(equalTo(updatedSnapshot))); + } + + @Test + public void notEqualsWhenAFileIsModified() throws Exception { + File file1 = new File(new File(this.folder, "folder1"), "file1"); + FileCopyUtils.copy("updatedcontent".getBytes(), file1); + FolderSnapshot updatedSnapshot = new FolderSnapshot(this.folder); + assertThat(this.initialSnapshot, not(equalTo(updatedSnapshot))); + } + + @Test + public void getChangedFilesSnapshotMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Snapshot must not be null"); + this.initialSnapshot.getChangedFiles(null); + } + + @Test + public void getChangedFilesSnapshotMustBeTheSameSourceFolder() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Snapshot source folder must be '" + this.folder + "'"); + this.initialSnapshot.getChangedFiles(new FolderSnapshot( + createTestFolderStructure())); + } + + @Test + public void getChangedFilesWhenNothingHasChanged() throws Exception { + FolderSnapshot updatedSnapshot = new FolderSnapshot(this.folder); + this.initialSnapshot.getChangedFiles(updatedSnapshot); + } + + @Test + public void getChangedFilesWhenAFileIsAddedAndDeletedAndChanged() throws Exception { + File folder1 = new File(this.folder, "folder1"); + File file1 = new File(folder1, "file1"); + File file2 = new File(folder1, "file2"); + File newFile = new File(folder1, "newfile"); + FileCopyUtils.copy("updatedcontent".getBytes(), file1); + file2.delete(); + newFile.createNewFile(); + FolderSnapshot updatedSnapshot = new FolderSnapshot(this.folder); + ChangedFiles changedFiles = this.initialSnapshot.getChangedFiles(updatedSnapshot); + assertThat(changedFiles.getSourceFolder(), equalTo(this.folder)); + assertThat(getChangedFile(changedFiles, file1).getType(), equalTo(Type.MODIFY)); + assertThat(getChangedFile(changedFiles, file2).getType(), equalTo(Type.DELETE)); + assertThat(getChangedFile(changedFiles, newFile).getType(), equalTo(Type.ADD)); + } + + private ChangedFile getChangedFile(ChangedFiles changedFiles, File file) { + for (ChangedFile changedFile : changedFiles) { + if (changedFile.getFile().equals(file)) { + return changedFile; + } + } + return null; + } + + private File createTestFolderStructure() throws IOException { + File root = this.temporaryFolder.newFolder(); + File folder1 = new File(root, "folder1"); + folder1.mkdirs(); + FileCopyUtils.copy("abc".getBytes(), new File(folder1, "file1")); + FileCopyUtils.copy("abc".getBytes(), new File(folder1, "file2")); + return root; + } + +}