Skip to content

Commit a9cc0c7

Browse files
committed
add the ut for loading skill from nested jar
1 parent 1d660ed commit a9cc0c7

File tree

2 files changed

+315
-0
lines changed

2 files changed

+315
-0
lines changed

agentscope-core/src/test/java/io/agentscope/core/skill/repository/ClasspathSkillRepositoryTest.java

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,34 @@
2222
import static org.junit.jupiter.api.Assertions.assertTrue;
2323

2424
import io.agentscope.core.skill.AgentSkill;
25+
import java.io.ByteArrayOutputStream;
2526
import java.io.IOException;
27+
import java.io.InputStream;
28+
import java.net.URI;
2629
import java.net.URL;
2730
import java.net.URLClassLoader;
31+
import java.net.URLConnection;
32+
import java.net.URLStreamHandler;
33+
import java.nio.channels.SeekableByteChannel;
2834
import java.nio.charset.StandardCharsets;
35+
import java.nio.file.AccessMode;
36+
import java.nio.file.CopyOption;
37+
import java.nio.file.DirectoryStream;
38+
import java.nio.file.FileStore;
39+
import java.nio.file.FileSystem;
2940
import java.nio.file.Files;
41+
import java.nio.file.LinkOption;
42+
import java.nio.file.OpenOption;
3043
import java.nio.file.Path;
44+
import java.nio.file.attribute.BasicFileAttributes;
45+
import java.nio.file.attribute.FileAttribute;
46+
import java.nio.file.attribute.FileAttributeView;
47+
import java.nio.file.spi.FileSystemProvider;
3148
import java.util.List;
49+
import java.util.Map;
50+
import java.util.Set;
3251
import java.util.jar.JarEntry;
52+
import java.util.jar.JarFile;
3353
import java.util.jar.JarOutputStream;
3454
import org.junit.jupiter.api.AfterEach;
3555
import org.junit.jupiter.api.DisplayName;
@@ -232,6 +252,70 @@ public URL getResource(String name) {
232252
}
233253
}
234254

255+
@Test
256+
@DisplayName("Should load skills from Spring Boot nested lib JAR (BOOT-INF/lib/)")
257+
void testLoadFromSpringBootNestedLibJar() throws Exception {
258+
// Simulates the URL pattern used by Spring Boot 3.2+ for multi-module projects:
259+
// jar:nested:/opt/app/xxx-app.jar/!BOOT-INF/lib/nested-skill.jar!/jar-skills
260+
Path outerJarPath =
261+
createSpringBootNestedLibTestJar(
262+
"nested-lib-skill", "Nested Lib Skill", "Nested lib content");
263+
264+
// Extract the inner JAR from the outer to a temp file,
265+
// simulating Spring Boot's runtime resolution of nested JARs
266+
Path innerJarPath = extractInnerJar(outerJarPath, "BOOT-INF/lib/nested-skill.jar");
267+
268+
// Configure the test FileSystemProvider so that ZipFileSystemProvider can
269+
// resolve the nested: URI to the extracted inner JAR path.
270+
// In production, Spring Boot's NestedFileSystemProvider handles this.
271+
TestNestedFileSystemProvider.configuredInnerJarPath = innerJarPath;
272+
try {
273+
// Build a ClassLoader that returns a jar:nested: format URL.
274+
ClassLoader nestedClassLoader =
275+
new ClassLoader(ClassLoader.getSystemClassLoader()) {
276+
@Override
277+
public URL getResource(String name) {
278+
if ("jar-skills".equals(name)) {
279+
try {
280+
String nestedUrlStr =
281+
"jar:nested:"
282+
+ outerJarPath
283+
+ "/!BOOT-INF/lib/nested-skill.jar!/"
284+
+ name;
285+
return new URL(
286+
null,
287+
nestedUrlStr,
288+
new URLStreamHandler() {
289+
@Override
290+
protected URLConnection openConnection(URL u)
291+
throws IOException {
292+
throw new UnsupportedOperationException(
293+
"nested URL for test only");
294+
}
295+
});
296+
} catch (Exception e) {
297+
throw new RuntimeException(e);
298+
}
299+
}
300+
return super.getResource(name);
301+
}
302+
};
303+
304+
repository =
305+
new ClasspathSkillRepositoryWithClassLoader("jar-skills", nestedClassLoader);
306+
307+
assertTrue(repository.isJarEnvironment(), "Should detect JAR environment");
308+
309+
AgentSkill skill = repository.getSkill("nested-lib-skill");
310+
assertNotNull(skill);
311+
assertEquals("nested-lib-skill", skill.getName());
312+
assertEquals("Nested Lib Skill", skill.getDescription());
313+
assertTrue(skill.getSkillContent().contains("Nested lib content"));
314+
} finally {
315+
TestNestedFileSystemProvider.configuredInnerJarPath = null;
316+
}
317+
}
318+
235319
// ==================== getSource Tests ====================
236320

237321
@Test
@@ -517,6 +601,99 @@ private Path createSpringBootTestJar(String skillName, String description, Strin
517601
return jarPath;
518602
}
519603

604+
/**
605+
* Creates a test fat JAR simulating a Spring Boot multi-module project. The
606+
* outer JAR contains
607+
* BOOT-INF/lib/nested-skill.jar, which itself contains
608+
* jar-skills/{skillName}/SKILL.md.
609+
*
610+
* <p>
611+
* This simulates the URL pattern:
612+
* jar:nested:/opt/app/xxx-app.jar/!BOOT-INF/lib/nested-skill.jar!/jar-skills
613+
*/
614+
private Path createSpringBootNestedLibTestJar(
615+
String skillName, String description, String content) throws IOException {
616+
// First, create the inner JAR (the library module jar)
617+
byte[] innerJarBytes = createInnerSkillJar(skillName, description, content);
618+
619+
// Then, create the outer fat JAR containing the inner JAR at BOOT-INF/lib/
620+
Path outerJarPath = tempDir.resolve(skillName + "-nested-springboot.jar");
621+
try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(outerJarPath))) {
622+
// Add Spring Boot directory structure
623+
jos.putNextEntry(new JarEntry("BOOT-INF/"));
624+
jos.closeEntry();
625+
jos.putNextEntry(new JarEntry("BOOT-INF/lib/"));
626+
jos.closeEntry();
627+
628+
// Embed the inner JAR as a nested entry (STORED, not compressed)
629+
JarEntry nestedJarEntry = new JarEntry("BOOT-INF/lib/nested-skill.jar");
630+
nestedJarEntry.setMethod(JarEntry.STORED);
631+
nestedJarEntry.setSize(innerJarBytes.length);
632+
nestedJarEntry.setCompressedSize(innerJarBytes.length);
633+
java.util.zip.CRC32 crc = new java.util.zip.CRC32();
634+
crc.update(innerJarBytes);
635+
nestedJarEntry.setCrc(crc.getValue());
636+
jos.putNextEntry(nestedJarEntry);
637+
jos.write(innerJarBytes);
638+
jos.closeEntry();
639+
}
640+
641+
return outerJarPath;
642+
}
643+
644+
/**
645+
* Creates an inner JAR byte array containing skills at
646+
* jar-skills/{skillName}/SKILL.md.
647+
*/
648+
private byte[] createInnerSkillJar(String skillName, String description, String content)
649+
throws IOException {
650+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
651+
try (JarOutputStream jos = new JarOutputStream(baos)) {
652+
// Add parent directory
653+
jos.putNextEntry(new JarEntry("jar-skills/"));
654+
jos.closeEntry();
655+
656+
// Add skill directory
657+
jos.putNextEntry(new JarEntry("jar-skills/" + skillName + "/"));
658+
jos.closeEntry();
659+
660+
// Add SKILL.md
661+
String skillMd =
662+
"---\n"
663+
+ "name: "
664+
+ skillName
665+
+ "\n"
666+
+ "description: "
667+
+ description
668+
+ "\n"
669+
+ "---\n"
670+
+ content;
671+
672+
JarEntry entry = new JarEntry("jar-skills/" + skillName + "/SKILL.md");
673+
jos.putNextEntry(entry);
674+
jos.write(skillMd.getBytes(StandardCharsets.UTF_8));
675+
jos.closeEntry();
676+
}
677+
return baos.toByteArray();
678+
}
679+
680+
/**
681+
* Extracts an inner JAR from an outer JAR to a temp file. This simulates how
682+
* Spring Boot
683+
* resolves nested JARs at runtime.
684+
*/
685+
private Path extractInnerJar(Path outerJarPath, String innerEntryName) throws IOException {
686+
Path innerJarPath = tempDir.resolve("extracted-inner.jar");
687+
try (JarFile outerJar = new JarFile(outerJarPath.toFile())) {
688+
JarEntry innerEntry = outerJar.getJarEntry(innerEntryName);
689+
assertNotNull(innerEntry, "Inner JAR entry should exist: " + innerEntryName);
690+
try (InputStream is = outerJar.getInputStream(innerEntry)) {
691+
Files.copy(is, innerJarPath);
692+
}
693+
}
694+
return innerJarPath;
695+
}
696+
520697
/**
521698
* Custom adapter that uses a specific ClassLoader for testing JAR loading.
522699
*/
@@ -527,4 +704,141 @@ public ClasspathSkillRepositoryWithClassLoader(String resourcePath, ClassLoader
527704
super(resourcePath, classLoader);
528705
}
529706
}
707+
708+
/**
709+
* Test-only {@link FileSystemProvider} for the {@code nested:} scheme.
710+
*
711+
* <p>
712+
* In Spring Boot 3.2+, the {@code nested:} scheme is handled by Spring Boot's
713+
* {@code NestedFileSystemProvider} from {@code spring-boot-loader}. In our test
714+
* environment (without Spring Boot), this provider simulates the same behavior.
715+
*
716+
* <p>
717+
* When {@link ClasspathSkillRepository} processes a {@code jar:nested:} URI,
718+
* the
719+
* JDK's {@code ZipFileSystemProvider} internally calls
720+
* {@code Path.of(new URI("nested:..."))} to locate the JAR file. This provider
721+
* intercepts that call and returns the path to the extracted inner JAR.
722+
*
723+
* <p>
724+
* Registered via SPI in
725+
* {@code META-INF/services/java.nio.file.spi.FileSystemProvider}.
726+
*/
727+
public static class TestNestedFileSystemProvider extends FileSystemProvider {
728+
729+
/**
730+
* Path to the extracted inner JAR. Must be set before creating a
731+
* {@link ClasspathSkillRepository} with a {@code jar:nested:} URL.
732+
*/
733+
static volatile Path configuredInnerJarPath;
734+
735+
@Override
736+
public String getScheme() {
737+
return "nested";
738+
}
739+
740+
/**
741+
* Returns the configured inner JAR path for the given {@code nested:} URI.
742+
*
743+
* <p>
744+
* Called by {@code ZipFileSystemProvider.uriToPath()} when it encounters
745+
* a {@code nested:} URI like
746+
* {@code nested:/path/outer.jar/!BOOT-INF/lib/inner.jar}.
747+
*/
748+
@Override
749+
public Path getPath(URI uri) {
750+
if (configuredInnerJarPath == null) {
751+
throw new IllegalStateException(
752+
"TestNestedFileSystemProvider.configuredInnerJarPath not set");
753+
}
754+
return configuredInnerJarPath;
755+
}
756+
757+
// ---- All methods below are not used; required by abstract contract ----
758+
759+
@Override
760+
public FileSystem newFileSystem(URI uri, Map<String, ?> env) {
761+
throw new UnsupportedOperationException();
762+
}
763+
764+
@Override
765+
public FileSystem getFileSystem(URI uri) {
766+
throw new UnsupportedOperationException();
767+
}
768+
769+
@Override
770+
public SeekableByteChannel newByteChannel(
771+
Path path, Set<? extends OpenOption> options, FileAttribute<?>... attrs) {
772+
throw new UnsupportedOperationException();
773+
}
774+
775+
@Override
776+
public DirectoryStream<Path> newDirectoryStream(
777+
Path dir, DirectoryStream.Filter<? super Path> filter) {
778+
throw new UnsupportedOperationException();
779+
}
780+
781+
@Override
782+
public void createDirectory(Path dir, FileAttribute<?>... attrs) {
783+
throw new UnsupportedOperationException();
784+
}
785+
786+
@Override
787+
public void delete(Path path) {
788+
throw new UnsupportedOperationException();
789+
}
790+
791+
@Override
792+
public void copy(Path source, Path target, CopyOption... options) {
793+
throw new UnsupportedOperationException();
794+
}
795+
796+
@Override
797+
public void move(Path source, Path target, CopyOption... options) {
798+
throw new UnsupportedOperationException();
799+
}
800+
801+
@Override
802+
public boolean isSameFile(Path path, Path path2) {
803+
throw new UnsupportedOperationException();
804+
}
805+
806+
@Override
807+
public boolean isHidden(Path path) {
808+
throw new UnsupportedOperationException();
809+
}
810+
811+
@Override
812+
public FileStore getFileStore(Path path) {
813+
throw new UnsupportedOperationException();
814+
}
815+
816+
@Override
817+
public void checkAccess(Path path, AccessMode... modes) {
818+
throw new UnsupportedOperationException();
819+
}
820+
821+
@Override
822+
public <V extends FileAttributeView> V getFileAttributeView(
823+
Path path, Class<V> type, LinkOption... options) {
824+
throw new UnsupportedOperationException();
825+
}
826+
827+
@Override
828+
public <A extends BasicFileAttributes> A readAttributes(
829+
Path path, Class<A> type, LinkOption... options) {
830+
throw new UnsupportedOperationException();
831+
}
832+
833+
@Override
834+
public Map<String, Object> readAttributes(
835+
Path path, String attributes, LinkOption... options) {
836+
throw new UnsupportedOperationException();
837+
}
838+
839+
@Override
840+
public void setAttribute(Path path, String attribute, Object value, LinkOption... options) {
841+
throw new UnsupportedOperationException();
842+
}
843+
}
530844
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
io.agentscope.core.skill.repository.ClasspathSkillRepositoryTest$TestNestedFileSystemProvider

0 commit comments

Comments
 (0)