"""Real Maven + JDT LS regression: python3 verify.py JDT_HOME (Java 25; Maven uses JAVA_HOME).""" import json import os import pathlib import platform import queue import shutil import subprocess import sys import tempfile import threading import time import xml.etree.ElementTree as ET home = pathlib.Path(sys.argv[1]).resolve() bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-kotlin-bridge.jar' work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-kotlin-bridge-', dir=os.environ.get('TMPDIR'))).resolve() copied_bundle = work/'openidea-kotlin-bridge.jar' shutil.copyfile(bundle, copied_bundle) bundle = copied_bundle projects = work / 'projects' single = projects / 'single' multi = projects / 'multi' lib = multi / 'channel-lib' app = multi / 'app' for directory in [single, lib, app]: (directory / 'src/main/java/probe').mkdir(parents=True) plugin = '''org.jetbrains.kotlinkotlin-maven-plugin2.0.10kotlin-compileprocess-sourcescompile1.8src/main/javaorg.apache.maven.pluginsmaven-compiler-plugin3.13.0''' plugin = plugin.replace('', 'kotlin-testprocess-test-sourcestest-compile1.8src/test/java') stdlib = 'org.jetbrains.kotlinkotlin-stdlib2.0.10' properties = '88' prefix = '4.0.0' (single / 'pom.xml').write_text(prefix + 'probesingle1' + properties + stdlib + plugin + '') (multi / 'pom.xml').write_text(prefix + 'probereactor1pom' + properties + 'channel-libapp') parent = 'probereactor1' (lib / 'pom.xml').write_text(prefix + parent + 'channel-lib' + stdlib + plugin + '') (app / 'pom.xml').write_text(prefix + parent + 'appprobechannel-lib1') source = 'package probe;\npublic class Caller {\n public Channel channel() {\n return Channel.SMS;\n }\n}\n' for directory in [single, app]: (directory / 'src/main/java/probe/Caller.java').write_text(source) for directory in [single, lib]: (directory / 'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n') (single/'src/test/java/probe').mkdir(parents=True) test_source = source.replace('Caller', 'TestCaller').replace('Channel', 'TestChannel') (single/'src/test/java/probe/TestCaller.java').write_text(test_source) (single/'src/test/java/probe/TestChannel.kt').write_text('package probe\nenum class TestChannel { SMS }\n') config = work / 'config' config.mkdir() base = {'Darwin': 'config_mac', 'Windows': 'config_win'}.get(platform.system(), 'config_linux') names = [base + '_arm', base] if platform.machine().lower() in ('arm64', 'aarch64') else [base] shutil.copyfile(next(home / n / 'config.ini' for n in names if (home/n/'config.ini').exists()), config/'config.ini') proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home/'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work/'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=(work/'stderr.log').open('w')) messages = queue.Queue() lock = threading.Lock() transcript = [] seq = 0 def send(message): data = json.dumps({'jsonrpc': '2.0', **message}).encode() with lock: proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data) proc.stdin.flush() def read(): while True: headers = {} while True: line = proc.stdout.readline() if not line: return if line == b'\r\n': break key, value = line.decode().split(':', 1) headers[key.lower()] = value.strip() message = json.loads(proc.stdout.read(int(headers['content-length']))) if 'method' in message and 'id' in message: send({'id':message['id'], 'result': [] if message['method']=='workspace/configuration' else None}) else: messages.put(message) threading.Thread(target=read, daemon=True).start() def request(method, params): global seq seq += 1 send({'id':seq, 'method':method, 'params':params}) deadline = time.monotonic() + 240 while time.monotonic() < deadline: message = messages.get(timeout=max(1, deadline-time.monotonic())) transcript.append(message) if message.get('id') == seq: if 'error' in message: raise RuntimeError(message['error']) return message.get('result') raise TimeoutError(method) def command(snapshot): response = request('workspace/executeCommand', {'command':'openidea.kotlin.replace', 'arguments':[snapshot]}) assert isinstance(response, (int, float)), response return response def build(directory, revision, expected=True): staging = work / revision result = subprocess.run(['mvn', '-B', '-fae', '-Dstyle.color=never', f'-Dmaven.ext.class.path={bundle}', f'-Dopenidea.kotlin.staging={staging}/staging', 'test-compile'], cwd=directory, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) (work/f'{revision}.log').write_bytes(result.stdout) modules = json.loads((staging/'modules.json').read_text()) eligible = all(m['kotlinGoals'] and all(g['status'] == 'success' for g in m['kotlinGoals']) for m in modules if m['hasKotlin']) assert eligible == expected, (modules, result.stdout.decode()) evidence[revision + '-build'] = {'exitCode':result.returncode, 'modules':modules, 'eligible':eligible} return modules def library(module, revision, test=False, name="Channel"): directory = work/'published'/revision directory.mkdir(parents=True) output = module['testOutput' if test else 'output'] for file in pathlib.Path(output).rglob('TestChannel*.class' if test else name+'*.class'): destination = directory/file.relative_to(output) destination.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(file, destination) assert list(directory.rglob('*.class')), module return {'path':str(directory), 'test':test} def state(directory, label, expected, test=False): file = directory/('src/test/java/probe/TestCaller.java' if test else 'src/main/java/probe/Caller.java') deadline = time.monotonic() + 30 while True: time.sleep(0.25) request('workspace/symbol', {'query':'Caller'}) reports = [m['params'] for m in transcript if m.get('method')=='textDocument/publishDiagnostics' and m['params']['uri']==file.as_uri()] errors = [d for d in reports[-1]['diagnostics'] if d.get('severity')==1] if reports else None if errors is not None and ((errors == []) == expected): break if time.monotonic() > deadline: raise AssertionError((label, errors)) completion = request('textDocument/completion', {'textDocument':{'uri':file.as_uri()}, 'position':{'line':3,'character':27 if test else 23}}) items = completion.get('items', []) if isinstance(completion, dict) else completion or [] labels = [item['label'] for item in items] definition = request('textDocument/definition', {'textDocument':{'uri':file.as_uri()}, 'position':{'line':2,'character':12}}) result = {'errors':errors, 'completion':labels, 'definition':definition} evidence[label] = result assert errors is not None, (label, "no published diagnostics") assert (errors == []) == expected, (label, errors) assert any(label.startswith('SMS') for label in labels) == expected, (label, labels) assert bool(definition) == expected, (label, definition) if expected: location = definition[0] assert location.get('uri', location.get('targetUri', '')).startswith('jdt:'), definition return result evidence = {'work':str(work)} try: init = request('initialize', {'processId':None, 'rootUri':projects.as_uri(), 'workspaceFolders':[{'uri':projects.as_uri(), 'name':'projects'}], 'capabilities':{'textDocument':{'completion':{'completionItem':{'snippetSupport':True}}, 'definition':{'linkSupport':True}}}, 'initializationOptions':{'bundles':[str(bundle)], 'extendedClientCapabilities':{'classFileContentsSupport':True}, 'settings':{'java':{'import':{'gradle':{'enabled':False}, 'maven':{'enabled':True}}, 'configuration':{'updateBuildConfiguration':'automatic'}}}}}) send({'method':'initialized', 'params':{}}) request('java/buildWorkspace', True) for directory, test in [(single,False), (app,False), (single,True)]: file = directory/('src/test/java/probe/TestCaller.java' if test else 'src/main/java/probe/Caller.java') send({'method':'textDocument/didOpen','params':{'textDocument':{'uri':file.as_uri(),'languageId':'java','version':1,'text':test_source if test else source}}}) # Allow reconcile jobs to publish their first marker batch. time.sleep(2) state(single, 'single-before', False) state(app, 'multi-before', False) state(single, 'test-before', False, True) def raw(directory): entries = ET.parse(directory/'.classpath').getroot() return [ET.tostring(e).decode().strip() for e in entries if not any(a.get('name')=='ai.openidea.kotlin' for a in e.findall('./attributes/attribute'))] before = {str(d):raw(d) for d in [single, lib, app]} sm = build(single, 'single-build')[0] assert not (pathlib.Path(sm['output'])/'probe/TestCaller.class').exists() assert (pathlib.Path(sm['testOutput'])/'probe/TestCaller.class').is_file() mm = build(multi, 'multi-build') lm = next(m for m in mm if 'channel-lib' in m['key']) sl, ml = library(sm, 'single'), library(lm, 'multi') snapshot = [{'projectUri':single.as_uri(),'libraries':[sl, library(sm, 'single-test', True)]}, {'projectUri':lib.as_uri(),'libraries':[ml]}, {'projectUri':app.as_uri(),'libraries':[ml]}] command(snapshot) time.sleep(2) state(single, 'single-after', True) state(app, 'multi-after', True) state(single, 'test-after', True, True) assert all(raw(d) == before[str(d)] for d in [single,lib,app]), 'changed unowned entries' # Simulate m2e removing the extra entries during a configuration update. send({'method':'java/projectConfigurationsUpdate','params':{'identifiers':[{'uri':(d/'pom.xml').as_uri()} for d in [single,lib,app]]}}) time.sleep(3) state(single, 'single-reimport', True) state(app, 'multi-reimport', True) assert all(raw(d) == before[str(d)] for d in [single,lib,app]), 'reimport changed unowned entries' # A saved API change must replace the immutable binary directory. (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS, EMAIL }\n') state(single, 'single-during-rebuild', True) changed = build(single, 'single-changed')[0] snapshot[0]['libraries'] = [library(changed, 'single-changed'), library(changed, 'single-test-changed', True)] command(snapshot) time.sleep(2) changed_state = state(single, 'single-new-enum-member', True) assert any(label.startswith('EMAIL') for label in changed_state['completion']) # Failed Kotlin compilation keeps both main and test APIs attached. (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { ??? }\n') broken = build(single, 'single-broken', expected=False)[0] assert any(g['status'] == 'failed' for g in broken['kotlinGoals']) state(single, 'single-broken-retains-api', True) state(single, 'single-broken-retains-test-api', True, True) # A javac failure skips test Kotlin targets, so this whole snapshot is retained. (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Delivery { SMS }\n') skipped = build(single, 'single-skipped-test', expected=False)[0] assert any(g['execution'].endswith(':test-compile') and g['status'] == 'skipped' for g in skipped['kotlinGoals']) assert list(pathlib.Path(skipped['output']).rglob('Delivery.class')) state(single, 'single-skipped-test-retains-api', True) # The reactor fixture has no test Kotlin goal. Publish despite downstream javac failure. kt = lib/'src/main/java/probe/Channel.kt' kt.write_text('package probe\nenum class Delivery { SMS }\n') renamed = build(multi, 'multi-renamed') renamed_lib = next(m for m in renamed if 'channel-lib' in m['key']) new_library = library(renamed_lib, 'multi-renamed', name='Delivery') assert not list(pathlib.Path(renamed_lib['output']).rglob('Channel.class')) snapshot[1]['libraries'] = snapshot[2]['libraries'] = [new_library] command(snapshot) state(app, 'multi-renamed', False) kt.unlink() snapshot[1]['libraries'] = snapshot[2]['libraries'] = [] command(snapshot) state(app, 'multi-deleted', False) kt.write_text('package probe\nenum class Channel { SMS }\n') restored = next(m for m in build(multi, 'multi-restored') if 'channel-lib' in m['key']) restored_library = library(restored, 'multi-restored') snapshot[1]['libraries'] = snapshot[2]['libraries'] = [restored_library] command(snapshot) state(app, 'multi-restored', True) (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n') repaired = build(single, 'single-repaired')[0] snapshot[0]['libraries'] = [library(repaired, 'single-repaired'), library(repaired, 'single-test-repaired', True)] command(snapshot) time.sleep(2) state(single, 'single-repaired', True) # No old API after explicit invalidation, and recovery is repeatable. command([]) time.sleep(2) state(single, 'single-withdrawn', False) state(app, 'multi-withdrawn', False) state(single, 'test-withdrawn', False, True) command(snapshot) time.sleep(2) state(single, 'single-recovered', True) state(app, 'multi-recovered', True) # Main sources must not gain visibility of test-only Kotlin libraries. leak = single/'src/main/java/probe/MainLeak.java' leak_source = test_source.replace('TestCaller', 'MainLeak') leak.write_text(leak_source) send({'method':'textDocument/didOpen','params':{'textDocument':{'uri':leak.as_uri(),'languageId':'java','version':1,'text':leak_source}}}) deadline = time.monotonic() + 30 while True: time.sleep(0.25) request('workspace/symbol', {'query':'MainLeak'}) reports = [m['params'] for m in transcript if m.get('method')=='textDocument/publishDiagnostics' and m['params']['uri']==leak.as_uri()] if reports: break assert time.monotonic() < deadline, 'no diagnostics for main/test isolation' evidence['main-test-isolation'] = reports[-1]['diagnostics'] assert any(d.get('severity') == 1 for d in reports[-1]['diagnostics']), reports[-1] # Compiler and JDT outputs must not share Kotlin classes. assert not list(single.glob('target/classes/**/Channel.class')) assert not list(lib.glob('target/classes/**/Channel.class')) # -fae runs independent Kotlin modules and records upstream-skipped targets. fail_reactor = work/'fail-at-end' fail_reactor.mkdir() (fail_reactor/'pom.xml').write_text(prefix + 'probefailure-reactor1pom' + properties + 'broken-javaindependentdownstream') failure_parent = 'probefailure-reactor1' for name in ['broken-java', 'independent', 'downstream']: module = fail_reactor/name (module/'src/main/java/probe').mkdir(parents=True) dependency = 'probebroken-java1org.jetbrains.kotlinkotlin-stdlib2.0.10' if name == 'downstream' else stdlib (module/'pom.xml').write_text(prefix + failure_parent + '' + name + '' + dependency + plugin + '') if name == 'broken-java': (module/'src/main/java/probe/Broken.java').write_text('package probe; public class Broken { Missing value; }') else: (module/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n') statuses = build(fail_reactor, 'fail-at-end-statuses', expected=False) independent = next(m for m in statuses if ':independent:' in m['key']) downstream = next(m for m in statuses if ':downstream:' in m['key']) assert all(g['status'] == 'success' for g in independent['kotlinGoals']), independent assert all(g['status'] == 'skipped' for g in downstream['kotlinGoals']), downstream assert list(pathlib.Path(independent['output']).rglob('Channel.class')) print(json.dumps(evidence, indent=2)) finally: (work/'evidence.json').write_text(json.dumps(evidence, indent=2)) (work/'transcript.json').write_text(json.dumps(transcript, indent=2)) proc.terminate() proc.wait(timeout=20) print('Evidence:', work, file=sys.stderr)