Skip to content

Commit 3bacc8d

Browse files
committed
test(contracts): extend lifecycle boundaries and layered execution evidence
Add UTF-8 byte-boundary, budget validation, running/queued cancellation, configuration copy and repeated timeout regression tests. Report both argv suites and process suites separately from live OpenCLI evidence. Runner extension observed local RED then GREEN: 13 self-tests pass. Archive only committed source alongside exact-head CI evidence for reproducible review.
1 parent 98e3db1 commit 3bacc8d

5 files changed

Lines changed: 183 additions & 4 deletions

File tree

.github/workflows/contracts.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ jobs:
8888
run: |
8989
mkdir -p contract-evidence
9090
git rev-parse HEAD > contract-evidence/head.txt
91+
git archive -o contract-evidence/source.tar HEAD
92+
sha256sum contract-evidence/source.tar > contract-evidence/source.sha256
9193
java -version > contract-evidence/java.txt 2>&1
9294
if ! bash ./mvnw -version > contract-evidence/maven.txt 2>&1; then
9395
cat contract-evidence/maven.txt
@@ -117,6 +119,9 @@ jobs:
117119
--maven-version-file contract-evidence/maven.txt \
118120
--exit-code "$(cat contract-evidence/maven.exit)" \
119121
--suite argv=io.github.easy4j.opencli.contract.OpenCliArgvContractTest \
122+
--suite argv=io.github.easy4j.opencli.contract.OpenCliStructuredArgvContractTest \
123+
--suite process=io.github.easy4j.opencli.contract.OpenCliProcessContractTest \
124+
--suite process=io.github.easy4j.opencli.contract.OpenCliProcessBoundaryTest \
120125
--output contract-evidence/report.json
121126
- name: Upload actual JVM evidence
122127
if: always()

scripts/contract_report.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import sys
1212
import xml.etree.ElementTree as ET
1313

14-
LAYERS = ('enumeration', 'argv', 'protocol', 'typed-result', 'real-execution')
14+
LAYERS = ('enumeration', 'argv', 'process', 'protocol', 'typed-result', 'real-execution')
1515

1616

1717
def _suite_summary(suite):
@@ -50,7 +50,6 @@ def build_report(reports_dir, *, head, branch, java_version, maven_version, exit
5050
raise ValueError('duplicate testsuite')
5151
suites[name] = _suite_summary(suite)
5252
except (ET.ParseError, OSError, KeyError, ValueError):
53-
# Do not copy testcase failure bodies or captured application output.
5453
problems.append('invalid or duplicate Surefire report: ' + path.name)
5554
layers = {}
5655
for layer in LAYERS:
@@ -71,7 +70,7 @@ def build_report(reports_dir, *, head, branch, java_version, maven_version, exit
7170
'head': head, 'branch': branch, 'javaVersion': java_version,
7271
'mavenVersion': maven_version, 'commandExitCode': exit_code,
7372
'layers': layers, 'problems': problems,
74-
'scope': 'Explicit Surefire suites only; synthetic argv probes are not live OpenCLI verification.'}
73+
'scope': 'Explicit Surefire suites only; synthetic argv/process probes are not live OpenCLI verification.'}
7574

7675

7776
def main(argv=None):

scripts/tests/test_contract_report.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ def test_complete_argv_evidence_passes_but_live_is_not_run(self):
4444
self.assertEqual(HEAD, report['head'])
4545
json.dumps(report)
4646

47+
def test_process_layer_is_separate_from_real_execution(self):
48+
self.xml()
49+
report = self.module.build_report(self.root, head=HEAD, branch='feature/2.0.x',
50+
java_version='fixture-jdk', maven_version='fixture-maven', exit_code=0,
51+
required_suites={'process': [SUITE]})
52+
self.assertEqual('PASS', report['status'])
53+
self.assertEqual(2, report['layers']['process']['executed'])
54+
self.assertEqual('NOT_RUN', report['layers']['real-execution']['status'])
55+
4756
def test_missing_report_fails(self):
4857
self.assertEqual('FAIL', self.report()['status'])
4958

src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@
66
import java.nio.file.Paths;
77
import java.util.Arrays;
88

9-
/** Offline, self-bounded child fixture. Release files allow test cleanup even against broken SDKs. */
9+
/** Offline, self-bounded child fixture. Release files allow cleanup even against a broken SDK. */
1010
public final class LifecycleProbe {
1111
private LifecycleProbe() { }
1212

1313
public static void main(String[] args) throws Exception {
1414
String mode = args[0];
15+
if ("utf8".equals(mode)) {
16+
System.out.write("中文".getBytes(StandardCharsets.UTF_8));
17+
return;
18+
}
1519
if ("stdout".equals(mode) || "stderr".equals(mode)) {
1620
byte[] block = new byte[8192];
1721
Arrays.fill(block, (byte) 'x');
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package io.github.easy4j.opencli.contract;
2+
3+
import io.github.easy4j.opencli.OpenCliProperties;
4+
import io.github.easy4j.opencli.core.OpenCliCancellationToken;
5+
import io.github.easy4j.opencli.core.OpenCliExecutionDetails.TerminationReason;
6+
import io.github.easy4j.opencli.core.OpenCliExecutor;
7+
import io.github.easy4j.opencli.core.OpenCliResult;
8+
import io.github.easy4j.opencli.exception.OpenCliException;
9+
import io.github.easy4j.opencli.exception.OpenCliTimeoutException;
10+
import java.io.File;
11+
import java.nio.charset.StandardCharsets;
12+
import java.nio.file.Files;
13+
import java.nio.file.Path;
14+
import java.util.ArrayList;
15+
import java.util.Arrays;
16+
import java.util.List;
17+
import java.util.concurrent.ExecutionException;
18+
import java.util.concurrent.ExecutorService;
19+
import java.util.concurrent.Executors;
20+
import java.util.concurrent.Future;
21+
import java.util.concurrent.TimeUnit;
22+
import org.junit.jupiter.api.Test;
23+
import org.junit.jupiter.api.Timeout;
24+
import org.junit.jupiter.api.io.TempDir;
25+
import static org.junit.jupiter.api.Assertions.*;
26+
27+
/** Additional finite-budget and immutable-submission regression vectors. */
28+
@Timeout(20)
29+
class OpenCliProcessBoundaryTest {
30+
@TempDir Path dir;
31+
32+
private static OpenCliProperties properties() {
33+
OpenCliProperties p = new OpenCliProperties();
34+
String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java";
35+
p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath());
36+
p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp",
37+
System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")),
38+
LifecycleProbe.class.getName())));
39+
p.setCommandTimeoutMillis(10000L);
40+
p.setMaxConcurrentExecutions(1);
41+
return p;
42+
}
43+
44+
private static void await(Path path) throws Exception {
45+
long started = System.nanoTime();
46+
while (!Files.exists(path) && System.nanoTime() - started < TimeUnit.SECONDS.toNanos(3L)) { Thread.sleep(10L); }
47+
assertTrue(Files.exists(path), "fixture did not start");
48+
}
49+
50+
@Test
51+
void utf8TruncationReportsBytesNotReencodedCharacters() {
52+
OpenCliProperties p = properties();
53+
p.setMaxStdoutBytes(4);
54+
OpenCliException failure = assertThrows(OpenCliException.class, () -> new OpenCliExecutor(p).invoke("utf8"));
55+
OpenCliResult partial = failure.getPartialResult();
56+
assertNotNull(partial);
57+
assertEquals(TerminationReason.OUTPUT_LIMIT, partial.getExecutionDetails().getTerminationReason());
58+
assertEquals(4L, partial.getExecutionDetails().getStdoutCapturedBytes());
59+
assertEquals(6L, partial.getExecutionDetails().getStdoutObservedBytes());
60+
assertEquals("中\uFFFD", partial.getStdout());
61+
assertTrue(partial.getExecutionDetails().isStdoutTruncated());
62+
}
63+
64+
@Test
65+
void invalidBudgetsFailBeforeChildCreation() {
66+
for (int choice = 0; choice < 5; choice++) {
67+
OpenCliProperties p = properties();
68+
if (choice == 0) { p.setMaxStdoutBytes(0); }
69+
if (choice == 1) { p.setMaxStderrBytes(-1); }
70+
if (choice == 2) { p.setCleanupGraceMillis(0); }
71+
if (choice == 3) { p.setCommandTimeoutMillis(Long.MAX_VALUE); }
72+
if (choice == 4) { p.setCleanupGraceMillis(Long.MAX_VALUE); }
73+
Path marker = dir.resolve("invalid-" + choice);
74+
assertThrows(IllegalArgumentException.class, () -> new OpenCliExecutor(p).invoke("write", marker.toString()));
75+
assertFalse(Files.exists(marker));
76+
}
77+
}
78+
79+
@Test
80+
void explicitCancellationOfRunningChildRetainsBoundedEvidence() throws Exception {
81+
OpenCliExecutor executor = new OpenCliExecutor(properties());
82+
OpenCliCancellationToken token = new OpenCliCancellationToken();
83+
Path heartbeat = dir.resolve("heartbeat");
84+
Path release = dir.resolve("release");
85+
ExecutorService worker = Executors.newSingleThreadExecutor();
86+
try {
87+
Future<OpenCliResult> future = worker.submit(() -> executor.invoke(
88+
Arrays.asList("heartbeat", heartbeat.toString(), release.toString()), token));
89+
await(heartbeat);
90+
token.cancel();
91+
ExecutionException failed = assertThrows(ExecutionException.class, () -> future.get(3, TimeUnit.SECONDS));
92+
assertTrue(failed.getCause() instanceof OpenCliException);
93+
OpenCliResult partial = ((OpenCliException) failed.getCause()).getPartialResult();
94+
assertEquals(TerminationReason.CANCELLED, partial.getExecutionDetails().getTerminationReason());
95+
assertTrue(partial.getExecutionDetails().isProcessStarted());
96+
assertFalse(partial.getExecutionDetails().isDescendantsExitConfirmed());
97+
String stopped = new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8);
98+
Thread.sleep(100L);
99+
assertEquals(stopped, new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8));
100+
assertTrue(executor.invoke("write", dir.resolve("next").toString()).isSuccess());
101+
} finally {
102+
Files.write(release, new byte[]{1});
103+
worker.shutdownNow();
104+
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
105+
}
106+
}
107+
108+
@Test
109+
void queuedTokenCancellationDoesNotStartTheWaitingChild() throws Exception {
110+
OpenCliExecutor executor = new OpenCliExecutor(properties());
111+
Path first = dir.resolve("first");
112+
Path second = dir.resolve("second");
113+
Path gate = dir.resolve("release");
114+
OpenCliCancellationToken token = new OpenCliCancellationToken();
115+
ExecutorService workers = Executors.newFixedThreadPool(2);
116+
try {
117+
Future<OpenCliResult> one = workers.submit(() -> executor.invoke("hold", first.toString(), gate.toString()));
118+
await(first);
119+
Future<OpenCliResult> two = workers.submit(() -> executor.invoke(Arrays.asList("write", second.toString()), token));
120+
Thread.sleep(100L);
121+
token.cancel();
122+
ExecutionException failed = assertThrows(ExecutionException.class, () -> two.get(2, TimeUnit.SECONDS));
123+
OpenCliResult partial = ((OpenCliException) failed.getCause()).getPartialResult();
124+
assertEquals(TerminationReason.CANCELLED, partial.getExecutionDetails().getTerminationReason());
125+
assertFalse(partial.getExecutionDetails().isProcessStarted());
126+
assertNull(partial.getExitCode());
127+
assertFalse(Files.exists(second));
128+
Files.write(gate, new byte[]{1});
129+
assertTrue(one.get(3, TimeUnit.SECONDS).isSuccess());
130+
} finally {
131+
Files.write(gate, new byte[]{1});
132+
workers.shutdownNow();
133+
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
134+
}
135+
}
136+
137+
@Test
138+
void timeoutAndCaptureConfigurationAreCopiedForReverseWorkers() {
139+
OpenCliProperties p = properties();
140+
p.setMaxStdoutBytes(123);
141+
p.setMaxStderrBytes(456);
142+
p.setCleanupGraceMillis(789);
143+
OpenCliProperties copy = p.copyForLocalCliExecution();
144+
assertEquals(123, copy.getMaxStdoutBytes());
145+
assertEquals(456, copy.getMaxStderrBytes());
146+
assertEquals(789L, copy.getCleanupGraceMillis());
147+
}
148+
149+
@Test
150+
void repeatedExecutionTimeoutsDoNotBecomeNonzeroOrIoFailures() {
151+
OpenCliProperties p = properties();
152+
p.setCommandTimeoutMillis(100L);
153+
OpenCliExecutor executor = new OpenCliExecutor(p);
154+
for (int i = 0; i < 5; i++) {
155+
Path marker = dir.resolve("timeout-" + i);
156+
OpenCliTimeoutException failure = assertThrows(OpenCliTimeoutException.class,
157+
() -> executor.invoke("hold", marker.toString(), dir.resolve("never-release").toString()));
158+
assertEquals(TerminationReason.EXECUTION_TIMEOUT, failure.getPartialResult().getExecutionDetails().getTerminationReason());
159+
assertFalse(executor.getProcessRuntime().isQuarantined());
160+
}
161+
}
162+
}

0 commit comments

Comments
 (0)