@creeperhost/modlens-mcp 1.6.24 → 1.6.26

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.
@@ -7,6 +7,8 @@ import { mkdtemp, rm } from 'node:fs/promises';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
11
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
10
12
 
11
13
  const root = await mkdtemp(join(tmpdir(), 'modlens-oauth-test-'));
12
14
  const entry = fileURLToPath(new URL('../dist/server.js', import.meta.url));
@@ -48,6 +50,8 @@ const provider = createServer(async (req, res) => {
48
50
  const data = new URLSearchParams(raw);
49
51
  if (data.get('grant_type') === 'refresh_token' && !data.get('refresh_token')?.startsWith('upstream-refresh-'))
50
52
  return write(400, { error: 'invalid_grant' });
53
+ if (data.get('grant_type') === 'refresh_token')
54
+ await new Promise(resolve => setTimeout(resolve, 100));
51
55
  if (data.get('grant_type') === 'authorization_code' && data.get('code') !== 'fake-code')
52
56
  return write(400, { error: 'invalid_grant' });
53
57
  const access = `upstream-${++serial}`;
@@ -98,7 +102,7 @@ const initialize = (access, extra = {}) => fetchManual(resource, { method: 'POST
98
102
  protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'oauth-test', version: '1' },
99
103
  } }) });
100
104
  let clientId;
101
- async function login() {
105
+ async function login(expectedName = 'ChatGPT & Codex <test>') {
102
106
  const verifier = randomBytes(32).toString('base64url');
103
107
  const pkce = createHash('sha256').update(verifier).digest('base64url');
104
108
  const callback = 'http://127.0.0.1:54321/callback';
@@ -113,9 +117,19 @@ async function login() {
113
117
  const done = await fetchManual(back.headers.get('location'));
114
118
  assert.equal(done.status, 200);
115
119
  assert.match(done.headers.get('content-security-policy'), /default-src 'none'/);
120
+ assert.match(done.headers.get('content-security-policy'), /form-action 'self' http:\/\/127\.0\.0\.1:54321;/,
121
+ 'consent form permits its registered OAuth callback after the approval redirect');
116
122
  const consentPage = await done.text();
117
123
  assert.match(consentPage, /CREEPERHOST/);
118
124
  assert.match(consentPage, /Allow access\?/);
125
+ if (expectedName) {
126
+ assert.ok(consentPage.includes(expectedName), 'registered application names are displayed and HTML-escaped');
127
+ assert.match(consentPage, /Name supplied by application/);
128
+ } else {
129
+ assert.match(consentPage, /Unnamed application/);
130
+ assert.match(consentPage, /No application name supplied/);
131
+ }
132
+ assert.match(consentPage, /Callback: http:\/\/127\.0\.0\.1:54321/);
119
133
  assert.match(consentPage, /Only continue if you started this request/);
120
134
  const approval = consentPage.match(/name="approval" value="([^"]+)"/)?.[1];
121
135
  assert.ok(approval);
@@ -165,26 +179,59 @@ try {
165
179
  const metadata = await (await fetchManual(`http://127.0.0.1:${mcpPort}/.well-known/oauth-protected-resource/mcp`)).json();
166
180
  assert.deepEqual(metadata.authorization_servers, [`http://127.0.0.1:${mcpPort}`]);
167
181
  const registration = await fetchManual(`http://127.0.0.1:${mcpPort}/oauth/register`, { method: 'POST',
168
- headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ redirect_uris: ['http://127.0.0.1:54321/callback'] }) });
182
+ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
183
+ redirect_uris: ['http://127.0.0.1:54321/callback'], client_name: 'ChatGPT & Codex <test>' }) });
169
184
  assert.equal(registration.status, 201);
170
- clientId = (await registration.json()).client_id;
185
+ const registered = await registration.json();
186
+ assert.equal(registered.client_name, 'ChatGPT & Codex <test>');
187
+ clientId = registered.client_id;
171
188
  const tokens = await login();
172
189
  assert.match(tokens.access_token, /^mla_/);
173
190
  assert.match(tokens.refresh_token, /^mlr_/);
174
191
  assert.equal((await initialize(tokens.access_token)).status, 200);
192
+ const runtimeClient = new Client({ name: 'hosted-runtime-test', version: '1' });
193
+ await runtimeClient.connect(new StreamableHTTPClientTransport(new URL(resource), {
194
+ requestInit: { headers: { Authorization: `Bearer ${tokens.access_token}` } },
195
+ }));
196
+ try {
197
+ const runtime = await runtimeClient.callTool({ name: 'runtime', arguments: {
198
+ action: 'setup', projectDir: '/local/mod-project', mcVersion: '1.7.10', mode: 'interactive',
199
+ } });
200
+ assert.equal(runtime.isError, undefined);
201
+ const plan = JSON.parse(runtime.content[0].text);
202
+ assert.equal(plan.executed, false);
203
+ assert.equal(plan.request.mcVersion, '1.7.10');
204
+ assert.ok(plan.invocation.arguments.includes('--runtime'));
205
+ } finally { await runtimeClient.close(); }
175
206
  assert.equal((await initialize(tokens.access_token, { 'x-modlens-user-id': 'forged' })).status, 401);
176
207
  await stop();
177
208
  await start();
178
209
  assert.equal((await initialize(tokens.access_token)).status, 200, 'grant survives restart');
179
- const refresh = await fetchManual(`http://127.0.0.1:${mcpPort}/oauth/token`, { method: 'POST',
180
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form({ grant_type: 'refresh_token',
181
- client_id: clientId, refresh_token: tokens.refresh_token, resource }) });
182
- assert.equal(refresh.status, 200);
183
- const rotated = await refresh.json();
210
+ const refreshes = await Promise.all(Array.from({ length: 4 }, () =>
211
+ fetchManual(`http://127.0.0.1:${mcpPort}/oauth/token`, { method: 'POST',
212
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form({ grant_type: 'refresh_token',
213
+ client_id: clientId, refresh_token: tokens.refresh_token, resource }) })));
214
+ for (const refresh of refreshes) assert.equal(refresh.status, 200, 'overlapping refresh requests share one rotation');
215
+ const rotations = await Promise.all(refreshes.map(response => response.json()));
216
+ assert.equal(new Set(rotations.map(response => response.refresh_token)).size, 1);
217
+ assert.equal((serverOutput.match(/"event":"refresh_issued"/g) ?? []).length, 1);
218
+ assert.equal((serverOutput.match(/"event":"refresh_joined"/g) ?? []).length, 3);
219
+ const rotated = rotations[0];
184
220
  const replay = await fetchManual(`http://127.0.0.1:${mcpPort}/oauth/token`, { method: 'POST',
185
221
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form({ grant_type: 'refresh_token',
186
222
  client_id: clientId, refresh_token: tokens.refresh_token }) });
187
223
  assert.equal(replay.status, 400);
224
+ const oldRefreshRef = createHash('sha256').update(tokens.refresh_token).digest('hex').slice(0, 12);
225
+ const refreshEvents = serverOutput.split('\n').filter(line => line.includes('[modlens] oauth '))
226
+ .map(line => JSON.parse(line.slice(line.indexOf('[modlens] oauth ') + '[modlens] oauth '.length)));
227
+ const issued = refreshEvents.find(event => event.event === 'refresh_issued');
228
+ const rejected = refreshEvents.find(event => event.event === 'refresh_rejected');
229
+ assert.equal(issued.previousRefreshRef, oldRefreshRef);
230
+ assert.equal(rejected.refreshRef, oldRefreshRef);
231
+ assert.equal(rejected.reason, 'not_current_or_missing');
232
+ assert.equal(issued.grant.length, 12);
233
+ assert.ok(refreshEvents.filter(event => event.event.startsWith('refresh_'))
234
+ .every(event => /^[a-f0-9]{12}$/.test(event.instance)));
188
235
  const firstSession = await initialize(rotated.access_token);
189
236
  assert.equal(firstSession.status, 200);
190
237
  const sessionId = firstSession.headers.get('mcp-session-id');
@@ -198,6 +245,10 @@ try {
198
245
  partner = false;
199
246
  assert.equal((await initialize(rotated.access_token)).status, 403);
200
247
  assert.equal((await initialize(rotated.access_token)).status, 401);
248
+ assert.match(serverOutput, /"event":"grant_revoked".*"reason":"profile_unauthorized"/);
249
+ assert.match(serverOutput, /"event":"grant_revoked".*"reason":"profile_access_denied"/);
250
+ for (const secret of [tokens.refresh_token, rotated.refresh_token, rotated.access_token])
251
+ assert.ok(!serverOutput.includes(secret), 'refresh diagnostics must not log bearer material');
201
252
  const denied = await fetchManual(`http://127.0.0.1:${mcpPort}/oauth/authorize?client_id=bad`);
202
253
  assert.equal(denied.status, 400);
203
254
  await stop();
@@ -207,6 +258,11 @@ try {
207
258
  const generalAccount = await login();
208
259
  assert.equal((await initialize(generalAccount.access_token)).status, 200,
209
260
  'self-hosted mode accepts a valid account without a claim rule');
261
+ const unnamedRegistration = await fetchManual(`http://127.0.0.1:${mcpPort}/oauth/register`, { method: 'POST',
262
+ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ redirect_uris: ['http://127.0.0.1:54321/callback'] }) });
263
+ assert.equal(unnamedRegistration.status, 201);
264
+ clientId = (await unnamedRegistration.json()).client_id;
265
+ await login(null);
210
266
  console.log('Hosted OAuth discovery, registration, sign-in, persistence, refresh, replay, session, revocation, partner, gateway-header and self-hosted checks passed.');
211
267
  } finally {
212
268
  await stop();
@@ -29,6 +29,7 @@ const env = { ...process.env, MCP_PORT: String(port), MCP_HOST: "127.0.0.1", MOD
29
29
  MODLENS_HOSTED_DAILY_BYTES: "15000", MODLENS_HOSTED_PERIOD_BYTES: "100000",
30
30
  MODLENS_HOSTED_DAILY_REQUESTS: "1000", MODLENS_HOSTED_MINUTE_REQUESTS: "120",
31
31
  MODLENS_HOSTED_RESPONSE_BYTES: "131072", MODLENS_AUTO_EMBED: "0", MODLENS_AUTO_GRAPH: "0" };
32
+ env.MODLENS_OPENAI_DOMAIN_CHALLENGE = "test-openai-domain-token";
32
33
  const source = Array.from({ length: 600 }, (_, i) => `// Synthetic fixture line ${i + 1}`).join("\n");
33
34
  const fixtureDir = join(env.MODLENS_CACHE_ROOT, "mc-decompiled-named", "1.21.1", "example");
34
35
  await mkdir(fixtureDir, { recursive: true });
@@ -67,10 +68,35 @@ const read = (client, startLine = 1) => client.callTool({ name: "mc_source", arg
67
68
  let passed = false;
68
69
  try {
69
70
  await start();
71
+ const domainChallenge = await fetch(new URL("/.well-known/openai-apps-challenge", endpoint));
72
+ assert.equal(domainChallenge.status, 200);
73
+ assert.equal(domainChallenge.headers.get("content-type"), "text/plain; charset=utf-8");
74
+ assert.equal(await domainChallenge.text(), env.MODLENS_OPENAI_DOMAIN_CHALLENGE);
70
75
  const unauthorized = await fetch(endpoint, { method: "POST", headers: { "x-modlens-user-id": "spoof" }, body: "{}" });
71
76
  assert.equal(unauthorized.status, 401);
72
77
  const publicUser = await connect("public-developer");
73
78
  const publicTools = await publicUser.client.listTools();
79
+ const expectedTools = ["report_issue", "runtime", "mod_license", "mod", "mod_bytecode", "mod_mixins",
80
+ "platform", "modpacks_ch", "mc_versions", "mc_source", "mappings", "docs", "primers",
81
+ "mc_registry", "mc_data", "mc_files", "mod_jar", "mod_data", "mod_tags", "mixin_scan",
82
+ "gradle", "project", "reports", "pack_tools", "analyze_crash_log", "find_missing_deps"];
83
+ assert.deepEqual(publicTools.tools.map(t => t.name).sort(), expectedTools.sort());
84
+ for (const tool of publicTools.tools) {
85
+ assert.ok(tool.title, `${tool.name} has no title`);
86
+ assert.ok(tool.description, `${tool.name} has no description`);
87
+ for (const hint of ["readOnlyHint", "openWorldHint", "destructiveHint"])
88
+ assert.equal(typeof tool.annotations?.[hint], "boolean", `${tool.name} has no ${hint}`);
89
+ }
90
+ const tool = name => publicTools.tools.find(t => t.name === name);
91
+ for (const [name, fields] of Object.entries({
92
+ mod: ["jarPath", "directory", "outputDir", "bundlePath"],
93
+ mappings: ["inputJar", "outputJar"], project: ["bundlePath"],
94
+ reports: ["savePath"], pack_tools: ["scriptsDir", "configDir"],
95
+ })) for (const field of fields)
96
+ assert.ok(!(field in tool(name).inputSchema.properties), `${name} advertises host-only ${field}`);
97
+ assert.equal(tool("project").annotations.destructiveHint, true);
98
+ assert.equal(tool("mc_source").annotations.readOnlyHint, false);
99
+ assert.equal(tool("report_issue").annotations.readOnlyHint, true);
74
100
  const publicMc = publicTools.tools.find(t => t.name === "mc_source");
75
101
  assert.ok(publicMc.inputSchema.properties.action.enum.includes("source_info"));
76
102
  assert.ok(publicMc.inputSchema.properties.action.enum.includes("index_status"));
@@ -137,6 +137,10 @@ try {
137
137
  const configuredRuntime = runtime("--request-file",runtimeRequest);
138
138
  assert.equal(configuredRuntime.state,"configured");
139
139
  assert.deepEqual(configuredRuntime.vmOptions,[configuredRuntime.vmOption,"-XX:StackShadowPages=32"]);
140
+ writeFileSync(runtimeRequest,JSON.stringify({action:"setup",projectDir:runtimeProject,mcVersion:"1.7.10",mode:"interactive"}));
141
+ const legacyRuntime = runtime("--request-file",runtimeRequest);
142
+ assert.deepEqual(legacyRuntime.vmOptions,[legacyRuntime.vmOption]);
143
+ assert.equal(legacyRuntime.mcVersion,"1.7.10");
140
144
  assert.equal(runtime("status").projects.length,1);
141
145
  assert.deepEqual(runtime("sessions"),[]);
142
146
  assert.ok(!existsSync(join(runtimeHome,"data")),"Runtime helper must not create a source database");
@@ -0,0 +1,81 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn, execFileSync } from 'node:child_process';
3
+ import { once } from 'node:events';
4
+ import { mkdtemp, mkdir, readdir, writeFile, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join, delimiter } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { RuntimeHub } from '../dist/runtime/hub.js';
9
+
10
+ const repo = fileURLToPath(new URL('..', import.meta.url));
11
+ const javaHome = process.env.MODLENS_TEST_JAVA_HOME;
12
+ const native = Boolean(process.env.MODLENS_GLFW_CLASSPATH);
13
+ if (!javaHome) throw new Error('Set MODLENS_TEST_JAVA_HOME to a Java 17 or newer JDK');
14
+ const executable = name => join(javaHome, 'bin', name + (process.platform === 'win32' ? '.exe' : ''));
15
+ const root = await mkdtemp(join(tmpdir(), 'modlens-runtime-glfw-'));
16
+ const project = join(root, 'glfw-project');
17
+ const hub = new RuntimeHub(join(root, 'cache'));
18
+ let game;
19
+ let gameDone;
20
+ try {
21
+ await mkdir(join(project, 'gradle', 'wrapper'), { recursive: true });
22
+ await writeFile(join(project, 'gradle', 'wrapper', 'gradle-wrapper.jar'), 'fixture');
23
+ const setup = await hub.setup(project, native ? 'hidden' : 'observe', 'runClient', true, '1.20.1');
24
+ assert.deepEqual(setup.vmOptions, [setup.vmOption]);
25
+ const files = [];
26
+ if (native) files.push(join(repo, 'runtime-agent/test/io/modlens/runtime/NativeGlfwSmoke.java'));
27
+ else {
28
+ const fixture = join(repo, 'runtime-agent/test/legacy-fixture/org/lwjgl/glfw');
29
+ files.push(...(await readdir(fixture)).filter(name => name.endsWith('.java')).map(name => join(fixture, name)));
30
+ files.push(join(repo, 'runtime-agent/test/legacy-fixture/org/lwjgl/opengl/GL11.java'));
31
+ files.push(join(repo, 'runtime-agent/test/io/modlens/runtime/GlfwSmoke.java'));
32
+ }
33
+ const classpath = [root, process.env.MODLENS_GLFW_CLASSPATH].filter(Boolean).join(delimiter);
34
+ execFileSync(executable('javac'), ['-cp', classpath, '-d', root, ...files]);
35
+ game = spawn(executable('java'), [`-Djava.io.tmpdir=${root}`, setup.vmOption, '-cp', classpath,
36
+ native ? 'io.modlens.runtime.NativeGlfwSmoke' : 'io.modlens.runtime.GlfwSmoke', root],
37
+ { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
38
+ let output = '', error = '';
39
+ game.stdout.on('data', bytes => { output += bytes; });
40
+ game.stderr.on('data', bytes => { error += bytes; });
41
+ gameDone = once(game, 'exit');
42
+ const deadline = Date.now() + 20000;
43
+ let session;
44
+ while (Date.now() < deadline) {
45
+ session = (await hub.list()).find(s => s.capabilities.target === '1.20.1' && s.capabilities.inputBackend === 'glfw');
46
+ if (session) break;
47
+ await new Promise(resolve => setTimeout(resolve, 100));
48
+ }
49
+ assert.ok(session, error || 'GLFW agent did not connect');
50
+ assert.equal(session.capabilities.jfr, true, JSON.stringify(await hub.events()));
51
+ const act = async command => {
52
+ try { return await hub.command(session.sessionId, command); }
53
+ catch (cause) { throw new Error(`${cause}\n${JSON.stringify(await hub.events())}\n${error}`); }
54
+ };
55
+ assert.equal((await act({ type: 'key', key: 'W', down: true, holdMs: 100 })).state, 'event_delivered');
56
+ assert.equal((await act({ type: 'mouse_button', button: 1, down: true, holdMs: 100 })).state, 'event_delivered');
57
+ assert.equal((await act({ type: 'mouse_move', x: 12, y: 5, relative: false })).state, 'event_delivered');
58
+ assert.equal((await act({ type: 'scroll', x: 0, y: 1 })).state, 'event_delivered');
59
+ assert.equal((await act({ type: 'text', text: 'A' })).state, 'event_delivered');
60
+ const frame = await act({ type: 'screenshot' });
61
+ assert.equal((await hub.artifact(session.sessionId, frame.artifact)).mimeType, 'image/png');
62
+ const recording = await act({ type: 'recording' });
63
+ assert.match(recording.artifact, /\.jfr$/);
64
+ const allocations = await act({ type: 'allocations', packagePrefix: 'io.modlens.runtime' });
65
+ const report = await hub.artifact(session.sessionId, allocations.artifact);
66
+ assert.equal(JSON.parse(report.text).kind, 'sampled_allocation_pressure');
67
+ assert.equal((await act({ type: 'mode', mode: 'hidden' })).mode, 'hidden');
68
+ await writeFile(join(root, 'finish'), 'done');
69
+ assert.equal((await gameDone)[0], 0, error);
70
+ for (const marker of ['KEY 87 1', 'BUTTON 0 1', 'MOVE 12.0 5.0', 'SCROLL 0.0 1.0', 'CHAR 65'])
71
+ assert.match(output, new RegExp(marker.replaceAll('.', '\\.')));
72
+ assert.equal((output.match(/KEY 87 1/g) ?? []).length, 1, 'physical key events must be filtered in observe mode');
73
+ console.log(`PASS: one packaged agent hooks ${native ? 'native ' : ''}GLFW callbacks, filters physical input, injects commands, captures a frame, and hides the window`);
74
+ } finally {
75
+ if (game && game.exitCode === null) {
76
+ game.kill();
77
+ await gameDone?.catch(() => {});
78
+ }
79
+ await hub.close();
80
+ await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
81
+ }
@@ -0,0 +1,71 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn, execFileSync } from 'node:child_process';
3
+ import { once } from 'node:events';
4
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { RuntimeHub } from '../dist/runtime/hub.js';
9
+
10
+ const repo = fileURLToPath(new URL('..', import.meta.url));
11
+ const javaHome = process.env.MODLENS_TEST_JAVA_HOME || process.env.MODLENS_JAVA8_HOME;
12
+ if (!javaHome) throw new Error('Set MODLENS_TEST_JAVA_HOME to a Java 8 or newer JDK');
13
+ const executable = name => join(javaHome, 'bin', name + (process.platform === 'win32' ? '.exe' : ''));
14
+ const root = await mkdtemp(join(tmpdir(), 'modlens-runtime-legacy-'));
15
+ const project = join(root, 'legacy-project');
16
+ const hub = new RuntimeHub(join(root, 'cache'));
17
+ let game;
18
+ try {
19
+ await mkdir(join(project, 'gradle', 'wrapper'), { recursive: true });
20
+ await writeFile(join(project, 'gradle', 'wrapper', 'gradle-wrapper.jar'), 'fixture');
21
+ const setup = await hub.setup(project, 'interactive', 'runClient', true, '1.7.10');
22
+ assert.deepEqual(setup.vmOptions, [setup.vmOption]);
23
+ const fixture = join(repo, 'runtime-agent/test/legacy-fixture');
24
+ execFileSync(executable('javac'), ['-d', root,
25
+ join(fixture, 'org/lwjgl/opengl/Display.java'),
26
+ join(fixture, 'org/lwjgl/opengl/GL11.java'),
27
+ join(fixture, 'org/lwjgl/input/Keyboard.java'),
28
+ join(fixture, 'org/lwjgl/input/Mouse.java'),
29
+ join(fixture, 'net/minecraft/CrashReport.java'),
30
+ join(repo, 'runtime-agent/test/io/modlens/runtime/LegacySmoke.java')]);
31
+ game = spawn(executable('java'), [setup.vmOption, '-cp', root,
32
+ 'io.modlens.runtime.LegacySmoke', root], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
33
+ let gameError = '', gameOutput = '';
34
+ game.stderr.on('data', bytes => { gameError += bytes; });
35
+ game.stdout.on('data', bytes => { gameOutput += bytes; });
36
+ const gameDone = once(game, 'exit');
37
+ const deadline = Date.now() + 20000;
38
+ let session;
39
+ while (Date.now() < deadline) {
40
+ session = (await hub.list()).find(s => s.capabilities.target === '1.7.10' && s.capabilities.inputBackend === 'lwjgl2');
41
+ if (session) break;
42
+ await new Promise(resolve => setTimeout(resolve, 100));
43
+ }
44
+ assert.ok(session, gameError || 'Legacy agent did not connect');
45
+ assert.equal(session.capabilities.inputBackend, 'lwjgl2');
46
+ assert.equal(session.capabilities.threads, true);
47
+ assert.ok(session.javaVersion);
48
+ assert.ok(session.metrics.heapUsed > 0);
49
+ const threads = await hub.command(session.sessionId, { type: 'threads' });
50
+ const report = await hub.artifact(session.sessionId, threads.artifact);
51
+ assert.match(report.text, /ModLens telemetry/);
52
+ assert.equal((await hub.command(session.sessionId, { type: 'mode', mode: 'observe' })).mode, 'observe');
53
+ assert.equal((await hub.command(session.sessionId, { type: 'key', key: 'W', down: true, holdMs: 100 })).state, 'event_delivered');
54
+ assert.equal((await hub.command(session.sessionId, { type: 'mouse_button', button: 1, down: true, holdMs: 100 })).state, 'event_delivered');
55
+ assert.equal((await hub.command(session.sessionId, { type: 'scroll', y: 1, x: 0 })).state, 'event_delivered');
56
+ const frame = await hub.command(session.sessionId, { type: 'screenshot' });
57
+ assert.equal((await hub.artifact(session.sessionId, frame.artifact)).mimeType, 'image/png');
58
+ assert.match(gameOutput, /KEY 17 true/);
59
+ assert.match(gameOutput, /MOUSE 0 0/);
60
+ assert.match(gameOutput, /MOUSE -1 120/);
61
+ const events = await hub.events();
62
+ assert.ok(events.events.some(e => e.type === 'agent_started'));
63
+ assert.ok(events.events.some(e => e.type === 'minecraft_crash'));
64
+ await writeFile(join(root, 'finish'), 'done');
65
+ assert.equal((await gameDone)[0], 0, gameError);
66
+ console.log(`PASS: one packaged agent runs on Java ${session.javaVersion}, hooks LWJGL2 keyboard/mouse events and screenshots, and reports diagnostics`);
67
+ } finally {
68
+ if (game && game.exitCode === null) game.kill();
69
+ await hub.close();
70
+ await rm(root, { recursive: true, force: true });
71
+ }
@@ -0,0 +1,59 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn, execFileSync } from 'node:child_process';
3
+ import { once } from 'node:events';
4
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join, delimiter } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { RuntimeHub } from '../dist/runtime/hub.js';
9
+
10
+ const repo = fileURLToPath(new URL('..', import.meta.url));
11
+ const javaHome = process.env.MODLENS_TEST_JAVA_HOME;
12
+ const lwjgl = process.env.MODLENS_LWJGL2_CLASSPATH;
13
+ if (!javaHome || !lwjgl) throw new Error('Set MODLENS_TEST_JAVA_HOME and MODLENS_LWJGL2_CLASSPATH');
14
+ const bin = name => join(javaHome, 'bin', name + (process.platform === 'win32' ? '.exe' : ''));
15
+ const root = await mkdtemp(join(tmpdir(), 'modlens-real-lwjgl2-'));
16
+ const project = join(root, 'project');
17
+ const agent = join(repo, 'dist/runtime/modlens-agent.jar');
18
+ const classpath = [root, agent, lwjgl].join(delimiter);
19
+ const hub = new RuntimeHub(join(root, 'cache'));
20
+ let game;
21
+ try {
22
+ await mkdir(join(project, 'gradle', 'wrapper'), { recursive: true });
23
+ await writeFile(join(project, 'gradle', 'wrapper', 'gradle-wrapper.jar'), 'fixture');
24
+ const setup = await hub.setup(project, 'observe', 'runClient', true, '1.7.10');
25
+ execFileSync(bin('javac'), ['-cp', classpath, '-d', root,
26
+ join(repo, 'runtime-agent/test/io/modlens/runtime/RealLwjgl2Smoke.java')]);
27
+ game = spawn(bin('java'), [setup.vmOption, '-cp', classpath,
28
+ 'io.modlens.runtime.RealLwjgl2Smoke', root],
29
+ { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
30
+ let output = '', error = '';
31
+ game.stdout.on('data', bytes => { output += bytes; });
32
+ game.stderr.on('data', bytes => { error += bytes; });
33
+ const done = once(game, 'exit');
34
+ const deadline = Date.now() + 20000;
35
+ let session;
36
+ while (Date.now() < deadline) {
37
+ session = (await hub.list()).find(s => s.capabilities.inputBackend === 'lwjgl2');
38
+ if (session) break;
39
+ await new Promise(resolve => setTimeout(resolve, 100));
40
+ }
41
+ assert.ok(session, error || 'LWJGL2 agent did not connect');
42
+ assert.equal((await hub.command(session.sessionId, { type: 'key', key: 'W', down: true, holdMs: 500 })).state, 'event_delivered');
43
+ assert.equal((await hub.command(session.sessionId, { type: 'mouse_button', button: 1, down: true, holdMs: 500 })).state, 'event_delivered');
44
+ assert.equal((await hub.command(session.sessionId, { type: 'scroll', x: 0, y: 1 })).state, 'event_delivered');
45
+ const events = (await hub.events()).events;
46
+ assert.ok(events.some(e => e.type === 'hooks_installed' && e.data.class === 'org/lwjgl/input/Keyboard'));
47
+ assert.ok(events.some(e => e.type === 'hooks_installed' && e.data.class === 'org/lwjgl/input/Mouse'));
48
+ assert.ok(events.some(e => e.type === 'hooks_installed' && e.data.class === 'org/lwjgl/opengl/Display'));
49
+ assert.match(output, /KEY 17 true/);
50
+ assert.match(output, /MOUSE 0 0/);
51
+ assert.match(output, /MOUSE -1 120/);
52
+ await writeFile(join(root, 'finish'), 'done');
53
+ assert.equal((await done)[0], 0, error);
54
+ console.log('PASS: one packaged Java 8 agent instruments actual LWJGL2 keyboard and mouse classes');
55
+ } finally {
56
+ if (game && game.exitCode === null) game.kill();
57
+ await hub.close();
58
+ await rm(root, { recursive: true, force: true });
59
+ }