@creeperhost/modlens-mcp 1.6.20 → 1.6.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ATTRIBUTION.md +8 -1
- package/README.md +77 -2
- package/RUNTIME.md +262 -0
- package/TESTING.md +8 -0
- package/dist/hosted-mod-license.d.ts +36 -0
- package/dist/hosted-mod-license.d.ts.map +1 -0
- package/dist/hosted-mod-license.js +65 -0
- package/dist/hosted-mod-license.js.map +1 -0
- package/dist/hosted-oauth.d.ts +36 -0
- package/dist/hosted-oauth.d.ts.map +1 -0
- package/dist/hosted-oauth.js +522 -0
- package/dist/hosted-oauth.js.map +1 -0
- package/dist/hosted-policy.d.ts +36 -0
- package/dist/hosted-policy.d.ts.map +1 -0
- package/dist/hosted-policy.js +354 -0
- package/dist/hosted-policy.js.map +1 -0
- package/dist/launcher.js +104 -89
- package/dist/launcher.js.map +1 -1
- package/dist/license-notices.d.ts +8 -0
- package/dist/license-notices.d.ts.map +1 -0
- package/dist/license-notices.js +10 -0
- package/dist/license-notices.js.map +1 -0
- package/dist/license-source-references.d.ts +12 -0
- package/dist/license-source-references.d.ts.map +1 -0
- package/dist/license-source-references.js +18 -0
- package/dist/license-source-references.js.map +1 -0
- package/dist/license-templates.d.ts +6 -0
- package/dist/license-templates.d.ts.map +1 -0
- package/dist/license-templates.js +55 -0
- package/dist/license-templates.js.map +1 -0
- package/dist/local-mod.d.ts +31 -0
- package/dist/local-mod.d.ts.map +1 -0
- package/dist/local-mod.js +71 -0
- package/dist/local-mod.js.map +1 -0
- package/dist/minecraft.d.ts +2 -0
- package/dist/minecraft.d.ts.map +1 -1
- package/dist/minecraft.js +16 -0
- package/dist/minecraft.js.map +1 -1
- package/dist/mod-license.d.ts +53 -0
- package/dist/mod-license.d.ts.map +1 -0
- package/dist/mod-license.js +617 -0
- package/dist/mod-license.js.map +1 -0
- package/dist/repositories/mcVersion.d.ts +1 -0
- package/dist/repositories/mcVersion.d.ts.map +1 -1
- package/dist/repositories/mcVersion.js +11 -0
- package/dist/repositories/mcVersion.js.map +1 -1
- package/dist/runtime/cli.d.ts +2 -0
- package/dist/runtime/cli.d.ts.map +1 -0
- package/dist/runtime/cli.js +163 -0
- package/dist/runtime/cli.js.map +1 -0
- package/dist/runtime/companion.d.ts +32 -0
- package/dist/runtime/companion.d.ts.map +1 -0
- package/dist/runtime/companion.js +135 -0
- package/dist/runtime/companion.js.map +1 -0
- package/dist/runtime/guidance.d.ts +73 -0
- package/dist/runtime/guidance.d.ts.map +1 -0
- package/dist/runtime/guidance.js +40 -0
- package/dist/runtime/guidance.js.map +1 -0
- package/dist/runtime/hub.d.ts +150 -0
- package/dist/runtime/hub.d.ts.map +1 -0
- package/dist/runtime/hub.js +561 -0
- package/dist/runtime/hub.js.map +1 -0
- package/dist/runtime/modlens-agent.jar +0 -0
- package/dist/runtime/protocol.d.ts +202 -0
- package/dist/runtime/protocol.d.ts.map +1 -0
- package/dist/runtime/protocol.js +91 -0
- package/dist/runtime/protocol.js.map +1 -0
- package/dist/runtime/requests.d.ts +367 -0
- package/dist/runtime/requests.d.ts.map +1 -0
- package/dist/runtime/requests.js +57 -0
- package/dist/runtime/requests.js.map +1 -0
- package/dist/server.js +186 -39
- package/dist/server.js.map +1 -1
- package/dist/tools/hosted-mc-index.d.ts +8 -0
- package/dist/tools/hosted-mc-index.d.ts.map +1 -0
- package/dist/tools/hosted-mc-index.js +92 -0
- package/dist/tools/hosted-mc-index.js.map +1 -0
- package/dist/tools/mc-fts.d.ts +2 -0
- package/dist/tools/mc-fts.d.ts.map +1 -1
- package/dist/tools/mc-fts.js +8 -0
- package/dist/tools/mc-fts.js.map +1 -1
- package/dist/tools/project.d.ts +7 -3
- package/dist/tools/project.d.ts.map +1 -1
- package/dist/tools/project.js +45 -3
- package/dist/tools/project.js.map +1 -1
- package/dist/tools/report-issue.d.ts +77 -0
- package/dist/tools/report-issue.d.ts.map +1 -0
- package/dist/tools/report-issue.js +92 -0
- package/dist/tools/report-issue.js.map +1 -0
- package/dist/tools/runtime.d.ts +6 -0
- package/dist/tools/runtime.d.ts.map +1 -0
- package/dist/tools/runtime.js +18 -0
- package/dist/tools/runtime.js.map +1 -0
- package/dist/tools/source.d.ts +1 -1
- package/dist/tools/source.d.ts.map +1 -1
- package/dist/tools/source.js +5 -1
- package/dist/tools/source.js.map +1 -1
- package/dist/tools/vanilla.d.ts +30 -1
- package/dist/tools/vanilla.d.ts.map +1 -1
- package/dist/tools/vanilla.js +184 -25
- package/dist/tools/vanilla.js.map +1 -1
- package/package.json +5 -1
- package/scripts/build-runtime-agent.mjs +64 -0
- package/scripts/fixtures/mod-license-corpus.json +154 -0
- package/scripts/gradle/modlens-runtime.init.gradle +17 -0
- package/scripts/runtime-test-client.mjs +36 -0
- package/scripts/test-hosted-oauth.mjs +186 -0
- package/scripts/test-hosted-policy.mjs +182 -0
- package/scripts/test-mod-licenses-live.mjs +86 -0
- package/scripts/test-package.mjs +44 -0
- package/scripts/test-project-http.mjs +46 -3
- package/scripts/test-runtime-gradle.mjs +107 -0
- package/scripts/test-runtime-minecraft.mjs +190 -0
- package/scripts/test-runtime-native.mjs +120 -0
- package/scripts/test-runtime-remote.mjs +209 -0
- package/scripts/update-license-templates.mjs +14 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Opt-in regression against real, immutable release JARs and live evidence APIs.
|
|
2
|
+
// Downloads test artifacts only; no credentials, ingestion or bulk source export.
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { once } from 'node:events';
|
|
9
|
+
import AdmZip from 'adm-zip';
|
|
10
|
+
import { inspectModLicense } from '../dist/mod-license.js';
|
|
11
|
+
|
|
12
|
+
const root = resolve('.scratch/license-live');
|
|
13
|
+
await mkdir(root, { recursive: true });
|
|
14
|
+
const cases = [
|
|
15
|
+
{ name: 'JEI', version: 'JhEb3Vl1', hash: 'a9418a15032d1653b4776763f2df7c54544a1c2314fe1c4269ba60d9bb51a204', license: 'MIT', target: 'mezz.jei.common.Constants' },
|
|
16
|
+
{ name: 'Jade', version: 'yd8FKCmx', hash: '067bb4b007e1d6f6b79f0afe99c91252aa825472b99a76d33a60d24442f9e92d', license: 'CC-BY-NC-SA-4.0', target: 'snownee.jade.Jade' },
|
|
17
|
+
{ name: 'Embeddium', version: 'UTbfe5d1', hash: 'eed3d1325f2acc2fd4e69bb495e5ccb91d962126ac5330f0582ebc2a3daf47fb', license: 'LGPL-3.0-only', target: 'org.embeddedt.embeddium.api.EmbeddiumConstants' },
|
|
18
|
+
];
|
|
19
|
+
if (process.argv.includes('--corpus')) {
|
|
20
|
+
cases.push(...JSON.parse(await readFile(new URL('./fixtures/mod-license-corpus.json', import.meta.url), 'utf8')));
|
|
21
|
+
}
|
|
22
|
+
const outcomes = [];
|
|
23
|
+
const digest = data => createHash('sha256').update(data).digest('hex');
|
|
24
|
+
for (const item of cases) {
|
|
25
|
+
const outcome = { name: item.name, version: item.version, expected: item.expected ?? 'hosted_allowed' };
|
|
26
|
+
outcomes.push(outcome);
|
|
27
|
+
try {
|
|
28
|
+
const jar = join(root, `${item.name}.jar`);
|
|
29
|
+
let data = await readFile(jar).catch(() => null);
|
|
30
|
+
if (!data || digest(data) !== item.hash) {
|
|
31
|
+
const response = await fetch(`https://api.modrinth.com/v2/version/${item.version}`, { headers: { 'User-Agent': 'modlens-mcp/license-regression' } });
|
|
32
|
+
assert.equal(response.status, 200);
|
|
33
|
+
const version = await response.json();
|
|
34
|
+
const url = new URL(version.files.find(f => f.primary)?.url ?? version.files[0].url);
|
|
35
|
+
assert.equal(url.origin, 'https://cdn.modrinth.com');
|
|
36
|
+
const download = await fetch(url, { redirect: 'error' });
|
|
37
|
+
assert.equal(download.status, 200);
|
|
38
|
+
data = Buffer.from(await download.arrayBuffer());
|
|
39
|
+
assert.equal(digest(data), item.hash, `${item.name} exact artifact`);
|
|
40
|
+
await writeFile(jar, data);
|
|
41
|
+
}
|
|
42
|
+
const decision = await inspectModLicense(jar);
|
|
43
|
+
await writeFile(join(root, `${item.name}.decision.json`), JSON.stringify(decision, null, 2));
|
|
44
|
+
Object.assign(outcome, { actual: decision.disposition, license: decision.selectedLicense, origin: decision.selectedOrigin, reason: decision.reason, notices: decision.notices.length });
|
|
45
|
+
assert.equal(decision.disposition, outcome.expected, `${item.name}: ${decision.reason}`);
|
|
46
|
+
if (decision.disposition === 'hosted_allowed') {
|
|
47
|
+
assert.equal(decision.selectedLicense, item.license);
|
|
48
|
+
assert.ok(decision.notices.length);
|
|
49
|
+
}
|
|
50
|
+
if (item.name === 'Jade') assert.equal(decision.conditions.noncommercialOnly, true);
|
|
51
|
+
if (item.name === 'Embeddium') {
|
|
52
|
+
assert.ok(decision.conditions.sourceArchive?.includes('87d1a75'));
|
|
53
|
+
assert.ok(decision.notices.some(n => n.name.includes('COPYING.LESSER')));
|
|
54
|
+
assert.ok(decision.notices.some(n => n.name.startsWith('dependency/')));
|
|
55
|
+
const response = await fetch(decision.conditions.sourceArchive, { method: 'HEAD' });
|
|
56
|
+
assert.equal(response.status, 200, 'complete corresponding-source archive is available');
|
|
57
|
+
}
|
|
58
|
+
if (process.argv.includes('--http') && decision.disposition === 'hosted_allowed' && item.target) {
|
|
59
|
+
const bundle = new AdmZip();
|
|
60
|
+
bundle.addFile('artifacts/0.jar', data);
|
|
61
|
+
bundle.addFile('manifest.json', Buffer.from(JSON.stringify({ format: 'modlens-project-v1', project: `license-${item.name}:`, sourceSet: 'main',
|
|
62
|
+
toolchain: 'gradle-compile-classpath', minecraftVersion: '', loaderVersion: '', mappings: 'compile-classpath', javaVersion: 21, transformations: [],
|
|
63
|
+
artifacts: [{ path: 'artifacts/0.jar', name: `${item.name}.jar`, size: data.length, sha256: item.hash, kind: 'classpath' }] })));
|
|
64
|
+
const bundlePath = join(root, `${item.name}.zip`), keyPath = join(root, `${item.name}.key`);
|
|
65
|
+
await writeFile(bundlePath, bundle.toBuffer());
|
|
66
|
+
await writeFile(keyPath, createHash('sha256').update(`disposable-test-${item.hash}`).digest('hex'));
|
|
67
|
+
const child = spawn(process.execPath, ['scripts/test-project-http.mjs', bundlePath, keyPath, item.target], { stdio: 'inherit',
|
|
68
|
+
env: { ...process.env, MODLENS_TEST_EXPECTED_LICENSE: item.license } });
|
|
69
|
+
const [code] = await once(child, 'exit');
|
|
70
|
+
assert.equal(code, 0, `${item.name} real HTTP hosted source path`);
|
|
71
|
+
outcome.http = 'passed';
|
|
72
|
+
} else if (process.argv.includes('--http')) {
|
|
73
|
+
outcome.http = decision.disposition === 'hosted_allowed' ? 'not run: no root class target' : 'not run: local-only decision';
|
|
74
|
+
}
|
|
75
|
+
outcome.passed = true;
|
|
76
|
+
console.log(`PASS: ${item.name}: ${decision.disposition}, ${decision.selectedLicense ?? 'no selected licence'} from ${decision.selectedOrigin ?? 'no selected origin'}, ${decision.notices.length} complete notices`);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
outcome.passed = false;
|
|
79
|
+
outcome.error = error instanceof Error ? error.message : String(error);
|
|
80
|
+
console.error(`FAIL: ${item.name}: ${outcome.error}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const summary = { checkedAt: new Date().toISOString(), total: outcomes.length, passed: outcomes.filter(o => o.passed).length, failed: outcomes.filter(o => !o.passed).length, outcomes };
|
|
84
|
+
await writeFile(join(root, process.argv.includes('--corpus') ? 'corpus-results.json' : 'baseline-results.json'), JSON.stringify(summary, null, 2));
|
|
85
|
+
console.log(`Licence corpus: ${summary.passed}/${summary.total} passed; ${summary.failed} failed.`);
|
|
86
|
+
if (summary.failed) process.exitCode = 1;
|
package/scripts/test-package.mjs
CHANGED
|
@@ -113,6 +113,36 @@ try {
|
|
|
113
113
|
assert.equal(initScript, join(installed, "scripts/gradle/modlens.init.gradle"));
|
|
114
114
|
assert.ok(statSync(initScript).size > 0, "Gradle exporter must ship in the npm package");
|
|
115
115
|
assert.ok(statSync(join(installed, "scripts/project-upload.mjs")).size > 0, "Remote uploader must ship in the npm package");
|
|
116
|
+
assert.ok(statSync(join(installed, "dist/runtime/modlens-agent.jar")).size > 0, "Optional runtime agent must ship prebuilt");
|
|
117
|
+
assert.ok(statSync(join(installed, "scripts/gradle/modlens-runtime.init.gradle")).size > 0, "Runtime launch integration must ship");
|
|
118
|
+
assert.ok(statSync(join(installed, "RUNTIME.md")).size > 0, "Runtime instructions must ship");
|
|
119
|
+
assert.ok(statSync(join(installed, "dist/license-templates.js")).size > 0, "License reference data must ship");
|
|
120
|
+
const localModRequest = join(root, "local-mod-request.json");
|
|
121
|
+
writeFileSync(localModRequest, JSON.stringify({ localJarPath: join(root, "not-opened.jar"), sha256: "a".repeat(64), className: "example.Fixture", acceptedLocalDecompilation: false }));
|
|
122
|
+
assert.throws(() => execFileSync(process.execPath, [join(installed, "dist/launcher.js"), "--local-mod", "--request-file", localModRequest], {
|
|
123
|
+
env: { ...env, MODLENS_HOME: join(root, "local-mod-home"), MCP_PORT: "9999" }, encoding: "utf8", stdio: "pipe", timeout: 30000,
|
|
124
|
+
}), error => error.status === 1 && String(error.stderr).includes("User acceptance required"));
|
|
125
|
+
assert.ok(!existsSync(join(root, "local-mod-home", "data")), "Local helper must not bootstrap a hosted database");
|
|
126
|
+
console.log("PASS: installed local decompilation helper refuses execution without user acceptance");
|
|
127
|
+
const runtimeHome=join(root,"runtime-only-home");
|
|
128
|
+
const runtimeEnv={...env,MODLENS_HOME:runtimeHome,MODLENS_CACHE_ROOT:join(root,"runtime-only-cache"),MCP_PORT:"9999"};
|
|
129
|
+
const runtime=(...arguments_)=>JSON.parse(execFileSync(process.execPath,[join(installed,"dist/launcher.js"),"--runtime",...arguments_],{env:runtimeEnv,encoding:"utf8",timeout:30000}));
|
|
130
|
+
const runtimeProject=join(root,"runtime project");
|
|
131
|
+
mkdirSync(join(runtimeProject,"gradle/wrapper"),{recursive:true});
|
|
132
|
+
writeFileSync(join(runtimeProject,"gradle/wrapper/gradle-wrapper.jar"),"fixture");
|
|
133
|
+
const runtimeRequest=join(root,"runtime-request.json");
|
|
134
|
+
writeFileSync(runtimeRequest,JSON.stringify({action:"setup",projectDir:runtimeProject,mode:"hidden"}));
|
|
135
|
+
try {
|
|
136
|
+
assert.ok(runtime("help").execution.includes("Remote MCP"));
|
|
137
|
+
const configuredRuntime = runtime("--request-file",runtimeRequest);
|
|
138
|
+
assert.equal(configuredRuntime.state,"configured");
|
|
139
|
+
assert.deepEqual(configuredRuntime.vmOptions,[configuredRuntime.vmOption,"-XX:StackShadowPages=32"]);
|
|
140
|
+
assert.equal(runtime("status").projects.length,1);
|
|
141
|
+
assert.deepEqual(runtime("sessions"),[]);
|
|
142
|
+
assert.ok(!existsSync(join(runtimeHome,"data")),"Runtime helper must not create a source database");
|
|
143
|
+
assert.ok(!existsSync(join(runtimeHome,".env")),"Runtime helper must not create MCP configuration");
|
|
144
|
+
} finally {runtime("stop");}
|
|
145
|
+
console.log("PASS: installed runtime helper setup, persistence and stop without local MCP/database setup");
|
|
116
146
|
const keyFile = join(root, "project-key.txt");
|
|
117
147
|
writeFileSync(keyFile, "a".repeat(64));
|
|
118
148
|
assert.deepEqual(JSON.parse(execFileSync(process.execPath, [join(installed, "dist/launcher.js"), "--project", "list", `--key-file=${keyFile}`], { env, encoding: "utf8" })), []);
|
|
@@ -181,6 +211,20 @@ try {
|
|
|
181
211
|
assert.ok(initialized.serverInfo);
|
|
182
212
|
send({ method: "notifications/initialized" });
|
|
183
213
|
const list = await request("tools/list", {});
|
|
214
|
+
assert.ok(list.tools.some(tool => tool.name === "runtime" && tool.description.includes("setup")), "AI must discover runtime setup through MCP");
|
|
215
|
+
assert.match(initialized.instructions, /runtime/);
|
|
216
|
+
assert.ok(list.tools.some(tool => tool.name === "report_issue"), "AI must discover GitHub issue reporting");
|
|
217
|
+
const issueDraft = await request("tools/call", { name: "report_issue", arguments: {
|
|
218
|
+
action: "prepare", title: "Package test fixture", summary: "A synthetic report; do not publish.",
|
|
219
|
+
} });
|
|
220
|
+
assert.ok(!issueDraft.isError);
|
|
221
|
+
const issuePlan = JSON.parse(issueDraft.content[0].text);
|
|
222
|
+
assert.equal(issuePlan.executed, false);
|
|
223
|
+
assert.equal(issuePlan.state, "draft_prepared");
|
|
224
|
+
assert.equal(issuePlan.repository.repo, "modlens-mcp");
|
|
225
|
+
const runtimeHelp = await request("tools/call", { name: "runtime", arguments: { action: "help" } });
|
|
226
|
+
assert.ok(!runtimeHelp.isError);
|
|
227
|
+
assert.match(runtimeHelp.content[0].text, /26\.3/);
|
|
184
228
|
assert.ok(list.tools.some(tool => tool.name === "mod"));
|
|
185
229
|
assert.ok(list.tools.some(tool => tool.name === "project"));
|
|
186
230
|
const stats = await request("tools/call", { name: "mod", arguments: { action: "stats" } });
|
|
@@ -11,6 +11,8 @@ import { once } from 'node:events';
|
|
|
11
11
|
import AdmZip from 'adm-zip';
|
|
12
12
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
13
13
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
14
|
+
import { licenseTemplates } from '../dist/license-templates.js';
|
|
15
|
+
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
14
16
|
|
|
15
17
|
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
18
|
const root = await mkdtemp(join(tmpdir(), 'modlens-http-smoke-'));
|
|
@@ -28,14 +30,22 @@ if (!process.argv[2]) {
|
|
|
28
30
|
const jar = new AdmZip();
|
|
29
31
|
jar.addFile('example/Target.class', Buffer.from('test class entry'));
|
|
30
32
|
jar.addFile('example/Target.java', Buffer.from('package example; public class Target { public int counter; }'));
|
|
33
|
+
jar.addFile('LICENSE', Buffer.from(licenseTemplates['0BSD'].text));
|
|
31
34
|
// An incompressible payload exercises multiple full upload chunks.
|
|
32
35
|
jar.addFile('payload.bin', randomBytes(2 * 1024 * 1024));
|
|
33
36
|
const bytes = jar.toBuffer();
|
|
34
37
|
const bundle = new AdmZip();
|
|
35
38
|
bundle.addFile('artifacts/0.jar', bytes);
|
|
39
|
+
const restricted = new AdmZip();
|
|
40
|
+
restricted.addFile('LICENSE', Buffer.from('All Rights Reserved'));
|
|
41
|
+
restricted.addFile('example/Restricted.class', Buffer.from('restricted class fixture'));
|
|
42
|
+
restricted.addFile('example/Restricted.java', Buffer.from('RESTRICTED_SOURCE_MUST_STAY_LOCAL'));
|
|
43
|
+
const restrictedBytes = restricted.toBuffer();
|
|
44
|
+
bundle.addFile('artifacts/1.jar', restrictedBytes);
|
|
36
45
|
bundle.addFile('manifest.json', Buffer.from(JSON.stringify({ format: 'modlens-project-v1', project: 'http-smoke:', sourceSet: 'main',
|
|
37
46
|
toolchain: 'gradle-compile-classpath', minecraftVersion: '', loaderVersion: '', mappings: 'compile-classpath', javaVersion: 21, transformations: [],
|
|
38
|
-
artifacts: [{ path: 'artifacts/0.jar', name: 'prepared.jar', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), kind: 'classpath' }
|
|
47
|
+
artifacts: [{ path: 'artifacts/0.jar', name: 'prepared.jar', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), kind: 'classpath' },
|
|
48
|
+
{ path: 'artifacts/1.jar', name: 'restricted.jar', size: restrictedBytes.length, sha256: createHash('sha256').update(restrictedBytes).digest('hex'), kind: 'classpath' }] })));
|
|
39
49
|
await writeFile(bundlePath, bundle.toBuffer());
|
|
40
50
|
}
|
|
41
51
|
const env = { ...process.env, MCP_PORT: String(port), MCP_HOST: '127.0.0.1', MODLENS_AUTO_EMBED: '0',
|
|
@@ -44,7 +54,9 @@ const server = spawn(process.execPath, [join(repo, 'dist/server.js')], { cwd: ro
|
|
|
44
54
|
let logs = '';
|
|
45
55
|
server.stderr.on('data', b => { logs += b; });
|
|
46
56
|
const stopped = once(server, 'exit');
|
|
47
|
-
const client = new Client({ name: 'project-http-test', version: '1.0.0' });
|
|
57
|
+
const client = new Client({ name: 'project-http-test', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } });
|
|
58
|
+
let acceptLocal = true;
|
|
59
|
+
client.setRequestHandler(ElicitRequestSchema, async () => acceptLocal ? { action: 'accept', content: { acceptLocal: true } } : { action: 'decline' });
|
|
48
60
|
let passed = false;
|
|
49
61
|
try {
|
|
50
62
|
await new Promise((resolveReady, reject) => {
|
|
@@ -69,6 +81,37 @@ try {
|
|
|
69
81
|
const source = await call('source', { className: target });
|
|
70
82
|
assert.ok(!source.isError, JSON.stringify(source));
|
|
71
83
|
assert.ok(JSON.parse(source.content[0].text).source.length > 0);
|
|
84
|
+
const complianceBlock = source.content.find(c => c.type === 'text' && c.text.includes('licenseCompliance'));
|
|
85
|
+
assert.ok(complianceBlock, 'required license notices must accompany source');
|
|
86
|
+
assert.ok(JSON.parse(source.content[0].text).source.split(/\r?\n/).length <= 200);
|
|
87
|
+
if (process.env.MODLENS_TEST_EXPECTED_LICENSE) {
|
|
88
|
+
const compliance = JSON.parse(complianceBlock.text).licenseCompliance[0];
|
|
89
|
+
assert.equal(compliance.license, process.env.MODLENS_TEST_EXPECTED_LICENSE);
|
|
90
|
+
assert.equal(compliance.conditions.outputLicense, compliance.license);
|
|
91
|
+
assert.ok(compliance.notices.every(n => n.licenseText.length > 0));
|
|
92
|
+
assert.ok(compliance.modifiedAt);
|
|
93
|
+
if (compliance.license === 'LGPL-3.0-only') assert.ok(compliance.conditions.sourceArchive);
|
|
94
|
+
if (compliance.license === 'CC-BY-NC-SA-4.0') assert.equal(compliance.conditions.noncommercialOnly, true);
|
|
95
|
+
}
|
|
96
|
+
if (!process.argv[2]) {
|
|
97
|
+
assert.ok(source.content.some(c => c.type === 'text' && c.text.includes('licenseCompliance')), 'required license notices must accompany source');
|
|
98
|
+
const restricted = await call('source', { className: 'example.Restricted' });
|
|
99
|
+
const fallback = JSON.parse(restricted.content[0].text);
|
|
100
|
+
assert.equal(fallback.licenseReview.disposition, 'local_only');
|
|
101
|
+
assert.equal(fallback.local.requiresUserConsent, true);
|
|
102
|
+
assert.ok(!JSON.stringify(restricted).includes('RESTRICTED_SOURCE_MUST_STAY_LOCAL'));
|
|
103
|
+
const searched = await call('search', { query: 'RESTRICTED_SOURCE' });
|
|
104
|
+
assert.deepEqual(JSON.parse(searched.content[0].text).results, []);
|
|
105
|
+
const consent = () => client.callTool({ name: 'mod_license', arguments: {
|
|
106
|
+
action: 'local_plan', projectKey: key, environmentId: imported.environmentId, className: 'example.Restricted',
|
|
107
|
+
} });
|
|
108
|
+
const accepted = JSON.parse((await consent()).content[0].text).local;
|
|
109
|
+
assert.equal(accepted.executed, false);
|
|
110
|
+
assert.equal(accepted.request.acceptedLocalDecompilation, true);
|
|
111
|
+
assert.equal(accepted.requiresUserConsent, false);
|
|
112
|
+
acceptLocal = false;
|
|
113
|
+
assert.equal(JSON.parse((await consent()).content[0].text).declined, true);
|
|
114
|
+
}
|
|
72
115
|
if (process.argv[5]) {
|
|
73
116
|
const members = await call('members', { className: target });
|
|
74
117
|
assert.ok(!members.isError, JSON.stringify(members));
|
|
@@ -88,7 +131,7 @@ try {
|
|
|
88
131
|
assert.equal(large.status, 413);
|
|
89
132
|
assert.ok(!logs.includes(key), 'server logs must redact project key');
|
|
90
133
|
assert.ok(!logs.includes('SENSITIVE_PAYLOAD'), 'server logs must redact upload bytes');
|
|
91
|
-
console.log(`PASS: HTTP upload, ${imported.classCount} classes, source query, project isolation, host-path rejection, body limits and log redaction`);
|
|
134
|
+
console.log(`PASS: HTTP upload, ${imported.classCount} classes, source query and complete notices, project isolation, host-path rejection, body limits and log redaction${process.argv[2] ? '' : ', restricted-source gating and local consent'}`);
|
|
92
135
|
passed = true;
|
|
93
136
|
} finally {
|
|
94
137
|
await client.close();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Validates the same wrapper/init-script path used by MCP and IntelliJ.
|
|
2
|
+
// Requires JAVA_HOME (25+) and MODLENS_TEST_GRADLE_HOME (a Gradle 9.6 distribution).
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, readdir, readFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { RuntimeHub } from "../dist/runtime/hub.js";
|
|
9
|
+
import { runtimeTestClient } from "./runtime-test-client.mjs";
|
|
10
|
+
|
|
11
|
+
const gradleHome = process.env.MODLENS_TEST_GRADLE_HOME;
|
|
12
|
+
const javaHome = process.env.JAVA_HOME;
|
|
13
|
+
if (!gradleHome || !javaHome) throw new Error("Set JAVA_HOME and MODLENS_TEST_GRADLE_HOME");
|
|
14
|
+
const root = await mkdtemp(join(tmpdir(), "modlens-runtime-gradle-"));
|
|
15
|
+
const project = join(root, "project with spaces");
|
|
16
|
+
await mkdir(join(project, "src/main/java"), { recursive: true });
|
|
17
|
+
await writeFile(join(project, "settings.gradle"), "rootProject.name = 'runtime-smoke'\n");
|
|
18
|
+
await writeFile(join(project, "gradle.properties"), "org.gradle.daemon=false\n");
|
|
19
|
+
await writeFile(
|
|
20
|
+
join(project, "build.gradle"),
|
|
21
|
+
`
|
|
22
|
+
plugins { id 'java' }
|
|
23
|
+
tasks.register('runClient', JavaExec) {
|
|
24
|
+
classpath = sourceSets.main.runtimeClasspath
|
|
25
|
+
mainClass = 'Fixture'
|
|
26
|
+
args 'agent'
|
|
27
|
+
}
|
|
28
|
+
tasks.register('plainRun', JavaExec) {
|
|
29
|
+
classpath = sourceSets.main.runtimeClasspath
|
|
30
|
+
mainClass = 'Fixture'
|
|
31
|
+
args 'plain'
|
|
32
|
+
}
|
|
33
|
+
`,
|
|
34
|
+
);
|
|
35
|
+
await writeFile(
|
|
36
|
+
join(project, "src/main/java/Fixture.java"),
|
|
37
|
+
`
|
|
38
|
+
public class Fixture {
|
|
39
|
+
public static void main(String[] args) throws Exception {
|
|
40
|
+
long count = java.lang.management.ManagementFactory.getRuntimeMXBean().getInputArguments()
|
|
41
|
+
.stream().filter(s -> s.startsWith("-javaagent:") && s.contains(".modlens")).count();
|
|
42
|
+
if (count != (args[0].equals("agent") ? 1 : 0)) throw new AssertionError("agent count " + count);
|
|
43
|
+
System.out.println("AGENT_COUNT=" + count);
|
|
44
|
+
var vmArgs = java.lang.management.ManagementFactory.getRuntimeMXBean().getInputArguments();
|
|
45
|
+
boolean hasShadowOption = vmArgs.stream().anyMatch(s -> s.startsWith("-XX:StackShadowPages="));
|
|
46
|
+
if (hasShadowOption != args[0].equals("agent")) throw new AssertionError("Stack setting leaked or missing: " + vmArgs);
|
|
47
|
+
if (count == 1) {
|
|
48
|
+
String shadowPages = java.lang.management.ManagementFactory.getPlatformMXBean(
|
|
49
|
+
com.sun.management.HotSpotDiagnosticMXBean.class).getVMOption("StackShadowPages").getValue();
|
|
50
|
+
if (!shadowPages.equals("32")) throw new AssertionError("Effective StackShadowPages=" + shadowPages);
|
|
51
|
+
System.out.println("STACK_SHADOW_PAGES=" + shadowPages);
|
|
52
|
+
}
|
|
53
|
+
if (count == 1) Thread.sleep(4000);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
`,
|
|
57
|
+
);
|
|
58
|
+
const launcher = (await readdir(join(gradleHome, "lib"))).find((n) =>
|
|
59
|
+
/^gradle-gradle-cli-main-.*\.jar$/.test(n),
|
|
60
|
+
);
|
|
61
|
+
if (!launcher) throw new Error("Expected a Gradle 9.6 distribution");
|
|
62
|
+
const java = join(javaHome, "bin", process.platform === "win32" ? "java.exe" : "java");
|
|
63
|
+
execFileSync(java, ["-jar", join(gradleHome, "lib", launcher), "--no-daemon", "--offline", "wrapper"], {
|
|
64
|
+
cwd: project,
|
|
65
|
+
stdio: "inherit",
|
|
66
|
+
timeout: 120000,
|
|
67
|
+
});
|
|
68
|
+
const hub = process.argv.includes("--helper")
|
|
69
|
+
? runtimeTestClient(root)
|
|
70
|
+
: new RuntimeHub(join(root, "bridge"));
|
|
71
|
+
try {
|
|
72
|
+
const setup = await hub.setup(project, "hidden");
|
|
73
|
+
const init = join(project, ".modlens/runtime/init.gradle");
|
|
74
|
+
const plain = execFileSync(
|
|
75
|
+
java,
|
|
76
|
+
[
|
|
77
|
+
"-jar",
|
|
78
|
+
join(gradleHome, "lib", launcher),
|
|
79
|
+
"--no-daemon",
|
|
80
|
+
"--offline",
|
|
81
|
+
"--no-configuration-cache",
|
|
82
|
+
"-I",
|
|
83
|
+
init,
|
|
84
|
+
"plainRun",
|
|
85
|
+
],
|
|
86
|
+
{ cwd: project, encoding: "utf8", timeout: 120000 },
|
|
87
|
+
);
|
|
88
|
+
assert.match(plain, /AGENT_COUNT=0/);
|
|
89
|
+
await hub.launch(setup.projectId, javaHome);
|
|
90
|
+
const deadline = Date.now() + 120000;
|
|
91
|
+
let done;
|
|
92
|
+
while (Date.now() < deadline) {
|
|
93
|
+
const events = (await hub.events()).events;
|
|
94
|
+
done = events.find((e) => e.type === "launch_log");
|
|
95
|
+
if (done) break;
|
|
96
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
97
|
+
}
|
|
98
|
+
assert.ok(done, "Gradle launch did not finish");
|
|
99
|
+
assert.match(done.data.text, /AGENT_COUNT=1/);
|
|
100
|
+
assert.match(done.data.text, /STACK_SHADOW_PAGES=32/);
|
|
101
|
+
assert.match(done.data.text, /BUILD SUCCESSFUL/);
|
|
102
|
+
assert.equal((await hub.list()).length, 1, "Only the game task should register");
|
|
103
|
+
assert.match(await readFile(setup.runConfiguration, "utf8"), /--init-script/);
|
|
104
|
+
console.log("Gradle optional agent launch passed. Evidence: " + root);
|
|
105
|
+
} finally {
|
|
106
|
+
await hub.close();
|
|
107
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Optional real-client validation with an isolated game directory and demo mode.
|
|
2
|
+
// Uses the explicitly supplied 26.3 manifest/client/assets; never reads user accounts/worlds.
|
|
3
|
+
import { readFile, writeFile, mkdir, mkdtemp, stat, copyFile } from "node:fs/promises";
|
|
4
|
+
import { join, delimiter, basename } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { RuntimeHub } from "../dist/runtime/hub.js";
|
|
11
|
+
import { runtimeTestClient } from "./runtime-test-client.mjs";
|
|
12
|
+
|
|
13
|
+
const env = process.env;
|
|
14
|
+
for (const k of [
|
|
15
|
+
"JAVA_HOME",
|
|
16
|
+
"MODLENS_TEST_MC_MANIFEST",
|
|
17
|
+
"MODLENS_TEST_MC_CLIENT",
|
|
18
|
+
"MODLENS_TEST_MC_ASSETS",
|
|
19
|
+
"MODLENS_TEST_MAVEN_CACHE",
|
|
20
|
+
])
|
|
21
|
+
if (!env[k]) throw new Error("Set " + k);
|
|
22
|
+
const manifest = JSON.parse(await readFile(env.MODLENS_TEST_MC_MANIFEST, "utf8"));
|
|
23
|
+
assert.equal(manifest.id, "26.3");
|
|
24
|
+
const repo = fileURLToPath(new URL("..", import.meta.url));
|
|
25
|
+
const root = await mkdtemp(join(tmpdir(), "modlens-minecraft-smoke-"));
|
|
26
|
+
console.log("Evidence directory: " + root);
|
|
27
|
+
const project = join(root, "project");
|
|
28
|
+
await mkdir(join(project, "gradle/wrapper"), { recursive: true });
|
|
29
|
+
await writeFile(join(project, "gradle/wrapper/gradle-wrapper.jar"), "fixture");
|
|
30
|
+
const classpath = [env.MODLENS_TEST_MC_CLIENT];
|
|
31
|
+
for (const lib of manifest.libraries) {
|
|
32
|
+
const osName = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "osx" : "linux";
|
|
33
|
+
if (lib.rules) {
|
|
34
|
+
let allowed = false;
|
|
35
|
+
for (const rule of lib.rules)
|
|
36
|
+
if (!rule.os || rule.os.name === osName) allowed = rule.action === "allow";
|
|
37
|
+
if (!allowed) continue;
|
|
38
|
+
}
|
|
39
|
+
const native = lib.name.split(":")[3];
|
|
40
|
+
if (
|
|
41
|
+
native?.includes("natives-") &&
|
|
42
|
+
native !==
|
|
43
|
+
`natives-${process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux"}${process.arch === "arm64" ? "-arm64" : ""}`
|
|
44
|
+
)
|
|
45
|
+
continue;
|
|
46
|
+
const [group, artifact, version] = lib.name.split(":");
|
|
47
|
+
const entry = lib.downloads?.artifact;
|
|
48
|
+
if (!entry) continue;
|
|
49
|
+
let path = join(env.MODLENS_TEST_MAVEN_CACHE, group, artifact, version, entry.sha1, basename(entry.path));
|
|
50
|
+
if (
|
|
51
|
+
!(await stat(path).then(
|
|
52
|
+
() => true,
|
|
53
|
+
() => false,
|
|
54
|
+
))
|
|
55
|
+
) {
|
|
56
|
+
path = join(root, "libraries", entry.path);
|
|
57
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
58
|
+
const r = await fetch(entry.url);
|
|
59
|
+
if (!r.ok) throw new Error("Download failed: " + entry.url);
|
|
60
|
+
const data = Buffer.from(await r.arrayBuffer());
|
|
61
|
+
assert.equal(createHash("sha1").update(data).digest("hex"), entry.sha1);
|
|
62
|
+
await writeFile(path, data);
|
|
63
|
+
}
|
|
64
|
+
classpath.push(path);
|
|
65
|
+
}
|
|
66
|
+
const hub = process.argv.includes("--helper")
|
|
67
|
+
? runtimeTestClient(root)
|
|
68
|
+
: new RuntimeHub(
|
|
69
|
+
join(root, "bridge"),
|
|
70
|
+
env.MODLENS_TEST_AGENT_JAR ?? join(repo, "dist/runtime/modlens-agent.jar"),
|
|
71
|
+
);
|
|
72
|
+
const setup = await hub.setup(project, "hidden");
|
|
73
|
+
const args = [
|
|
74
|
+
"--enable-native-access=ALL-UNNAMED",
|
|
75
|
+
"--add-exports",
|
|
76
|
+
"java.base/jdk.internal.misc=ALL-UNNAMED",
|
|
77
|
+
"-Xmx2G",
|
|
78
|
+
...(env.MODLENS_TEST_JVM_ARGS ? JSON.parse(env.MODLENS_TEST_JVM_ARGS) : []),
|
|
79
|
+
"-XX:ErrorFile=" + join(root, "hs_err_pid%p.log"),
|
|
80
|
+
...setup.vmOptions.filter(
|
|
81
|
+
(option) => !process.argv.includes("--baseline") || !option.startsWith("-javaagent:"),
|
|
82
|
+
),
|
|
83
|
+
"-cp",
|
|
84
|
+
classpath.join(delimiter),
|
|
85
|
+
manifest.mainClass,
|
|
86
|
+
"--username",
|
|
87
|
+
"ModLensTest",
|
|
88
|
+
"--version",
|
|
89
|
+
"26.3",
|
|
90
|
+
"--gameDir",
|
|
91
|
+
join(root, "game"),
|
|
92
|
+
"--assetsDir",
|
|
93
|
+
env.MODLENS_TEST_MC_ASSETS,
|
|
94
|
+
"--assetIndex",
|
|
95
|
+
env.MODLENS_TEST_MC_ASSET_INDEX ?? manifest.assetIndex.id,
|
|
96
|
+
"--uuid",
|
|
97
|
+
"00000000000000000000000000000001",
|
|
98
|
+
"--accessToken",
|
|
99
|
+
"0",
|
|
100
|
+
"--demo",
|
|
101
|
+
"--width",
|
|
102
|
+
"854",
|
|
103
|
+
"--height",
|
|
104
|
+
"480",
|
|
105
|
+
];
|
|
106
|
+
await mkdir(join(root, "game"), { recursive: true });
|
|
107
|
+
await writeFile(
|
|
108
|
+
join(root, "java.args"),
|
|
109
|
+
args.map((a) => '"' + a.replaceAll("\\", "/").replaceAll('"', '\\"') + '"').join("\n"),
|
|
110
|
+
);
|
|
111
|
+
const child = spawn(
|
|
112
|
+
join(env.JAVA_HOME, "bin", process.platform === "win32" ? "java.exe" : "java"),
|
|
113
|
+
["@" + join(root, "java.args")],
|
|
114
|
+
{ cwd: join(root, "game"), windowsHide: true },
|
|
115
|
+
);
|
|
116
|
+
let log = "";
|
|
117
|
+
child.stdout.on("data", (b) => {
|
|
118
|
+
log = (log + b).slice(-150000);
|
|
119
|
+
});
|
|
120
|
+
child.stderr.on("data", (b) => {
|
|
121
|
+
log = (log + b).slice(-150000);
|
|
122
|
+
});
|
|
123
|
+
const done = new Promise((ok) => child.once("exit", ok));
|
|
124
|
+
async function wait(fn, ms = 90000) {
|
|
125
|
+
const until = Date.now() + ms;
|
|
126
|
+
while (Date.now() < until) {
|
|
127
|
+
const r = await fn();
|
|
128
|
+
if (r) return r;
|
|
129
|
+
if (child.exitCode !== null)
|
|
130
|
+
throw new Error("Minecraft exited " + child.exitCode + "\n" + log.slice(-8000));
|
|
131
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
132
|
+
}
|
|
133
|
+
throw new Error("Timed out waiting for Minecraft\n" + log.slice(-8000));
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
if (process.argv.includes("--baseline")) {
|
|
137
|
+
await wait(() => log.includes("shulker_boxes.png-atlas"));
|
|
138
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
139
|
+
assert.equal(child.exitCode, null, "Uninstrumented baseline must remain alive after startup");
|
|
140
|
+
console.log("Uninstrumented Minecraft startup passed. " + root);
|
|
141
|
+
} else {
|
|
142
|
+
const session = await wait(async () =>
|
|
143
|
+
(await hub.list()).find(
|
|
144
|
+
(s) =>
|
|
145
|
+
s.capabilities.minecraftHooks && s.capabilities.window && s.state.observation?.gameLoaded,
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
await new Promise((r) => setTimeout(r, 1500)); // allow the startup overlay to fade
|
|
149
|
+
console.log("Connected Minecraft: " + JSON.stringify(session));
|
|
150
|
+
const frame = await hub.command(session.sessionId, { type: "screenshot" });
|
|
151
|
+
const artifact = await hub.artifact(session.sessionId, frame.artifact);
|
|
152
|
+
assert.equal(artifact.mimeType, "image/png");
|
|
153
|
+
await copyFile(artifact.path, join(root, "frame.png"));
|
|
154
|
+
await hub.command(session.sessionId, { type: "key", key: "TAB", down: true, holdMs: 100 });
|
|
155
|
+
await hub.command(session.sessionId, { type: "release_all" });
|
|
156
|
+
assert.equal(session.capabilities.jfr, true);
|
|
157
|
+
const allocations = await hub.command(session.sessionId, {
|
|
158
|
+
type: "allocations",
|
|
159
|
+
packagePrefix: "net.minecraft",
|
|
160
|
+
windowSeconds: 120,
|
|
161
|
+
});
|
|
162
|
+
const allocationArtifact = await hub.artifact(session.sessionId, allocations.artifact);
|
|
163
|
+
const report = JSON.parse(allocationArtifact.text);
|
|
164
|
+
assert.ok(report.matchedSamples > 0, "Expected Minecraft allocation samples with caller stacks");
|
|
165
|
+
await copyFile(allocationArtifact.path, join(root, "allocations.json"));
|
|
166
|
+
console.log("Minecraft 26.3 hidden client, screenshot, input and allocation report passed. " + root);
|
|
167
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
if (child.exitCode === null)
|
|
170
|
+
try {
|
|
171
|
+
await writeFile(
|
|
172
|
+
join(root, "threads.txt"),
|
|
173
|
+
execFileSync(
|
|
174
|
+
join(env.JAVA_HOME, "bin", process.platform === "win32" ? "jcmd.exe" : "jcmd"),
|
|
175
|
+
[String(child.pid), "Thread.print"],
|
|
176
|
+
{ timeout: 10000 },
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
await writeFile(join(root, "thread-error.txt"), String(e) + "\n" + e.stderr);
|
|
181
|
+
}
|
|
182
|
+
await writeFile(join(root, "client.log"), log);
|
|
183
|
+
await writeFile(
|
|
184
|
+
join(root, "evidence.json"),
|
|
185
|
+
JSON.stringify({ sessions: await hub.list(), events: await hub.events() }, null, 2),
|
|
186
|
+
);
|
|
187
|
+
if (child.exitCode === null) child.kill();
|
|
188
|
+
await done;
|
|
189
|
+
await hub.close();
|
|
190
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Real SDL smoke test. Set JAVA_HOME (25+) and MODLENS_SDL_CLASSPATH to the
|
|
2
|
+
// lwjgl + lwjgl-sdl Java JARs and matching native JARs. No game/account needed.
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises";
|
|
4
|
+
import { join, delimiter } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { RuntimeHub } from "../dist/runtime/hub.js";
|
|
10
|
+
|
|
11
|
+
if (!process.env.JAVA_HOME || !process.env.MODLENS_SDL_CLASSPATH)
|
|
12
|
+
throw new Error("Set JAVA_HOME and MODLENS_SDL_CLASSPATH.");
|
|
13
|
+
const repo = fileURLToPath(new URL("..", import.meta.url));
|
|
14
|
+
const root = await mkdtemp(join(tmpdir(), "modlens-sdl-smoke-"));
|
|
15
|
+
const project = join(root, "project");
|
|
16
|
+
await mkdir(join(project, "gradle/wrapper"), { recursive: true });
|
|
17
|
+
await writeFile(join(project, "gradle/wrapper/gradle-wrapper.jar"), "fixture");
|
|
18
|
+
const agent = join(repo, "dist/runtime/modlens-agent.jar");
|
|
19
|
+
const hub = new RuntimeHub(join(root, "bridge"), agent);
|
|
20
|
+
const bin = (n) => join(process.env.JAVA_HOME, "bin", n + (process.platform === "win32" ? ".exe" : ""));
|
|
21
|
+
execFileSync(
|
|
22
|
+
bin("javac"),
|
|
23
|
+
["-cp", agent, "-d", root, join(repo, "runtime-agent/test/io/modlens/runtime/NativeSmoke.java")],
|
|
24
|
+
{ stdio: "inherit" },
|
|
25
|
+
);
|
|
26
|
+
const setup = await hub.setup(project, "hidden");
|
|
27
|
+
const child = spawn(
|
|
28
|
+
bin("java"),
|
|
29
|
+
[
|
|
30
|
+
"--enable-native-access=ALL-UNNAMED",
|
|
31
|
+
setup.vmOption,
|
|
32
|
+
"-cp",
|
|
33
|
+
[root, agent, process.env.MODLENS_SDL_CLASSPATH].join(delimiter),
|
|
34
|
+
"io.modlens.runtime.NativeSmoke",
|
|
35
|
+
root,
|
|
36
|
+
],
|
|
37
|
+
{ windowsHide: true },
|
|
38
|
+
);
|
|
39
|
+
let log = "";
|
|
40
|
+
child.stdout.on("data", (b) => {
|
|
41
|
+
log += b;
|
|
42
|
+
});
|
|
43
|
+
child.stderr.on("data", (b) => {
|
|
44
|
+
log += b;
|
|
45
|
+
});
|
|
46
|
+
const exited = new Promise((ok, fail) => {
|
|
47
|
+
child.on("exit", (code) => ok(code));
|
|
48
|
+
child.on("error", fail);
|
|
49
|
+
});
|
|
50
|
+
async function wait(fn, ms = 10000) {
|
|
51
|
+
const until = Date.now() + ms;
|
|
52
|
+
while (Date.now() < until) {
|
|
53
|
+
const r = await fn();
|
|
54
|
+
if (r) return r;
|
|
55
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
56
|
+
}
|
|
57
|
+
throw new Error("Timed out\n" + log + "\n" + JSON.stringify(await hub.events()));
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const s = await wait(async () =>
|
|
61
|
+
(await hub.list()).find((s) => s.capabilities.inputBackend === "lwjgl-sdl3"),
|
|
62
|
+
);
|
|
63
|
+
assert.equal(s.state.mode, "hidden");
|
|
64
|
+
assert.equal(s.capabilities.window, true);
|
|
65
|
+
await hub.command(s.sessionId, { type: "key", key: "W", down: true, holdMs: 150 });
|
|
66
|
+
await wait(() => log.includes("RELEASE"));
|
|
67
|
+
await hub.command(s.sessionId, { type: "text", text: "hello 世界" });
|
|
68
|
+
await hub.command(s.sessionId, { type: "mouse_move", x: 12, y: -5, relative: true });
|
|
69
|
+
await hub.command(s.sessionId, { type: "mouse_button", button: 1, down: true, holdMs: 100 });
|
|
70
|
+
await hub.command(s.sessionId, { type: "scroll", x: 0, y: 1 });
|
|
71
|
+
for (const marker of ["KEY 26", "TEXT hello 世界", "MOTION", "BUTTON", "SCROLL"])
|
|
72
|
+
await wait(() => log.includes(marker));
|
|
73
|
+
const threads = await hub.command(s.sessionId, { type: "threads" });
|
|
74
|
+
assert.match(threads.artifact, /\.txt$/);
|
|
75
|
+
const recording = await hub.command(s.sessionId, { type: "recording" });
|
|
76
|
+
assert.match(recording.artifact, /\.jfr$/);
|
|
77
|
+
assert.match((await hub.artifact(s.sessionId, threads.artifact)).text, /ModLens telemetry/);
|
|
78
|
+
const recorded = await hub.artifact(s.sessionId, recording.artifact);
|
|
79
|
+
assert.equal((await readFile(recorded.path)).subarray(0, 3).toString(), "FLR");
|
|
80
|
+
const allocations = await hub.command(s.sessionId, {
|
|
81
|
+
type: "allocations",
|
|
82
|
+
packagePrefix: "io.modlens.runtime.NativeSmoke",
|
|
83
|
+
windowSeconds: 30,
|
|
84
|
+
limit: 20,
|
|
85
|
+
});
|
|
86
|
+
const report = JSON.parse((await hub.artifact(s.sessionId, allocations.artifact)).text);
|
|
87
|
+
assert.ok(report.matchedSamples > 0, "Expected real JFR allocation samples for the workload");
|
|
88
|
+
assert.ok(report.hotspots.some((h) => h.site.includes("NativeSmoke.allocateFixture")));
|
|
89
|
+
assert.ok(report.hotspots.every((h) => h.site.startsWith("io.modlens.runtime.NativeSmoke.")));
|
|
90
|
+
const noMatch = await hub.command(s.sessionId, {
|
|
91
|
+
type: "allocations",
|
|
92
|
+
packagePrefix: "no.such.mod",
|
|
93
|
+
windowSeconds: 30,
|
|
94
|
+
limit: 20,
|
|
95
|
+
});
|
|
96
|
+
const empty = JSON.parse((await hub.artifact(s.sessionId, noMatch.artifact)).text);
|
|
97
|
+
assert.equal(empty.matchedSamples, 0);
|
|
98
|
+
assert.deepEqual(empty.hotspots, []);
|
|
99
|
+
console.log("Allocation hotspot report matched " + report.matchedSamples + " real samples.");
|
|
100
|
+
await hub.command(s.sessionId, { type: "mode", mode: "observe" });
|
|
101
|
+
await hub.command(s.sessionId, { type: "key", key: "TAB", down: true, holdMs: 100 });
|
|
102
|
+
await hub.command(s.sessionId, { type: "mode", mode: "interactive" });
|
|
103
|
+
await assert.rejects(
|
|
104
|
+
hub.command(s.sessionId, { type: "key", key: "W", down: true, holdMs: 100 }),
|
|
105
|
+
/interactive mode/,
|
|
106
|
+
);
|
|
107
|
+
await hub.command(s.sessionId, { type: "mode", mode: "hidden" });
|
|
108
|
+
await hub.command(s.sessionId, { type: "release_all" });
|
|
109
|
+
await writeFile(join(root, "finish"), "done");
|
|
110
|
+
assert.equal(await exited, 0, log);
|
|
111
|
+
await wait(async () => (await hub.events()).events.some((e) => e.type === "uncaught_exception"));
|
|
112
|
+
await writeFile(
|
|
113
|
+
join(root, "evidence.json"),
|
|
114
|
+
JSON.stringify({ sessions: await hub.list(), events: await hub.events(), log }, null, 2),
|
|
115
|
+
);
|
|
116
|
+
console.log("SDL runtime smoke passed. Evidence: " + root);
|
|
117
|
+
} finally {
|
|
118
|
+
if (child.exitCode === null) child.kill();
|
|
119
|
+
await hub.close();
|
|
120
|
+
}
|