Add headless server and setup script
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 34s
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 42s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 1m13s
Release j3270 / Build & Publish Release (push) Successful in 1m8s

This commit is contained in:
2026-09-08 11:38:38 -04:00
parent f8fc0e4b22
commit 59925c2787
14 changed files with 2256 additions and 19 deletions
@@ -112,4 +112,48 @@ public class LiveHostHeadlessTest {
client.disconnect();
}
}
@Test
@DisplayName("Logon to live MVS host with guest account 'headless'")
public void testLiveMvsGuestLogin() throws Exception {
boolean reachable = false;
try (Socket probe = new Socket()) {
probe.connect(new InetSocketAddress(HOST, PORT), 3000);
reachable = true;
} catch (IOException e) {
logger.warning("Live host " + HOST + ":" + PORT + " unreachable: " + e.getMessage());
}
Assumptions.assumeTrue(reachable, "Skipping live test: " + HOST + ":" + PORT + " is not reachable");
ConnectionConfig config = new ConnectionConfig(HOST, PORT, TerminalModel.IBM_3279_2);
config.setConnectTimeoutMs(5000);
config.setSoTimeoutMs(10000);
Telnet3270Client client = new Telnet3270Client(config);
try {
boolean connected = client.connect(10000);
Assumptions.assumeTrue(connected, "Host connection established");
// Wait for initial screen buffer to populate
Thread.sleep(1500);
ScreenBuffer sb = client.getScreenBuffer();
String initialText = sb != null ? sb.getText().trim() : "";
logger.info("Initial screen before logon: " +
initialText.substring(0, Math.min(150, initialText.length())).replaceAll("\\s+", " "));
// Send guest account 'headless'
client.sendKeys("headless[enter]");
Thread.sleep(2000);
String responseText = sb != null ? sb.getText().trim() : "";
logger.info("Screen after typing 'headless': " +
responseText.substring(0, Math.min(150, responseText.length())).replaceAll("\\s+", " "));
assertNotNull(responseText, "Screen response should not be null");
assertFalse(responseText.isEmpty(), "Screen response should not be empty");
} finally {
client.disconnect();
}
}
}
@@ -0,0 +1,570 @@
package haus.nightmare.lib3270j.testutil;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.protocol.DS3270Constants;
import haus.nightmare.lib3270j.protocol.TN3270EConstants;
import haus.nightmare.lib3270j.protocol.TelnetConstants;
import java.io.*;
import java.net.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
import static haus.nightmare.lib3270j.protocol.TN3270EConstants.*;
import static haus.nightmare.lib3270j.protocol.TelnetConstants.*;
/**
* Lightweight, in-memory mock TN3270E server running on loopback (127.0.0.1).
*
* Implements RFC 2355 Telnet negotiation, Device-Type subnegotiation,
* BIND image dispatch, formatted 3270 data stream framing (Write / Erase-Write),
* Contention Resolution (SDI / KRI), Keep-Alive tracking, and disconnect simulation.
*/
public class Mock3270Server implements AutoCloseable {
private static final Logger log = Logger.getLogger(Mock3270Server.class.getName());
private final ServerSocket serverSocket;
private final int port;
private final EbcdicTranslator translator;
private final ExecutorService executor = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "Mock3270Server-Worker");
t.setDaemon(true);
return t;
});
private volatile boolean running = true;
private volatile Socket currentSocket;
private volatile OutputStream clientOut;
private volatile InputStream clientIn;
private boolean negotiateBindImage = true;
private boolean negotiateContentionResolution = true;
private boolean autoSendBind = true;
private String assignedDeviceType = "IBM-3279-2-E";
private String assignedLu = "MOCKLU01";
private int primaryRows = 24;
private int primaryCols = 80;
private int altRows = 24;
private int altCols = 80;
private int sequenceNumber = 0;
private final List<byte[]> receivedRecords = new CopyOnWriteArrayList<>();
private final AtomicInteger receivedNops = new AtomicInteger(0);
private final AtomicInteger receivedTimingMarks = new AtomicInteger(0);
private volatile CountDownLatch connectionLatch = new CountDownLatch(1);
private volatile CountDownLatch handshakeLatch = new CountDownLatch(1);
private volatile CountDownLatch dataReceivedLatch = new CountDownLatch(1);
public Mock3270Server() throws IOException {
this(0);
}
public Mock3270Server(int requestedPort) throws IOException {
this.serverSocket = new ServerSocket(requestedPort, 10, InetAddress.getByName("127.0.0.1"));
this.port = serverSocket.getLocalPort();
this.translator = new EbcdicTranslator();
executor.submit(this::acceptLoop);
log.info("Mock3270Server started on 127.0.0.1:" + port);
}
public int getPort() {
return port;
}
public void setNegotiateBindImage(boolean negotiate) {
this.negotiateBindImage = negotiate;
}
public void setNegotiateContentionResolution(boolean cr) {
this.negotiateContentionResolution = cr;
}
public void setAutoSendBind(boolean autoSendBind) {
this.autoSendBind = autoSendBind;
}
public void setAssignedDeviceType(String assignedDeviceType) {
this.assignedDeviceType = assignedDeviceType;
}
public void setAssignedLu(String assignedLu) {
this.assignedLu = assignedLu;
}
public void setDimensions(int pRows, int pCols, int aRows, int aCols) {
this.primaryRows = pRows;
this.primaryCols = pCols;
this.altRows = aRows;
this.altCols = aCols;
}
public boolean waitForConnection(long timeout, TimeUnit unit) throws InterruptedException {
if (currentSocket != null && currentSocket.isConnected() && !currentSocket.isClosed()) {
return true;
}
return connectionLatch.await(timeout, unit);
}
public boolean waitForHandshake(long timeout, TimeUnit unit) throws InterruptedException {
return handshakeLatch.await(timeout, unit);
}
public boolean waitForClientData(long timeout, TimeUnit unit) throws InterruptedException {
if (!receivedRecords.isEmpty()) {
return true;
}
return dataReceivedLatch.await(timeout, unit);
}
public List<byte[]> getReceivedRecords() {
return Collections.unmodifiableList(receivedRecords);
}
public byte[] getLastReceivedRecord() {
if (receivedRecords.isEmpty()) return null;
return receivedRecords.get(receivedRecords.size() - 1);
}
public int getReceivedNops() {
return receivedNops.get();
}
public int getReceivedTimingMarks() {
return receivedTimingMarks.get();
}
public void resetDataLatch() {
dataReceivedLatch = new CountDownLatch(1);
}
private void acceptLoop() {
while (running) {
try {
Socket s = serverSocket.accept();
s.setTcpNoDelay(true);
log.info("Mock3270Server: accepted client connection from " + s.getRemoteSocketAddress());
synchronized (this) {
this.currentSocket = s;
this.clientIn = new BufferedInputStream(s.getInputStream());
this.clientOut = new BufferedOutputStream(s.getOutputStream());
}
connectionLatch.countDown();
executor.submit(() -> handleClient(s));
} catch (SocketException e) {
if (!running) break;
} catch (Exception e) {
if (running) {
log.log(Level.WARNING, "Error in acceptLoop", e);
}
}
}
}
private void handleClient(Socket s) {
try {
// 1. Initial Telnet option offer
sendInitialNegotiation();
// 2. Read incoming telnet options and data loop
ByteArrayOutputStream recordBuf = new ByteArrayOutputStream();
ByteArrayOutputStream sbBuf = new ByteArrayOutputStream();
int b;
boolean inIAC = false;
int iacCommand = -1;
boolean inSB = false;
while (running && !s.isClosed() && (b = clientIn.read()) != -1) {
b &= 0xFF;
if (inSB) {
if (inIAC) {
if (b == SE) {
inSB = false;
inIAC = false;
handleSubnegotiation(sbBuf.toByteArray());
sbBuf.reset();
} else if (b == IAC) {
sbBuf.write(IAC);
inIAC = false;
} else {
inIAC = false;
}
} else if (b == IAC) {
inIAC = true;
} else {
sbBuf.write(b);
}
continue;
}
if (inIAC) {
if (iacCommand == -1) {
iacCommand = b;
switch (b) {
case IAC: // Escaped 0xFF in data stream
recordBuf.write(IAC);
inIAC = false;
iacCommand = -1;
break;
case SB:
inSB = true;
inIAC = false;
iacCommand = -1;
sbBuf.reset();
break;
case EOR:
// End of record framing
byte[] fullRecord = recordBuf.toByteArray();
recordBuf.reset();
inIAC = false;
iacCommand = -1;
onRecordReceived(fullRecord);
break;
case NOP:
receivedNops.incrementAndGet();
log.fine("Mock3270Server: RCVD IAC NOP");
inIAC = false;
iacCommand = -1;
break;
case DO:
case DONT:
case WILL:
case WONT:
// Option follows next byte
break;
default:
inIAC = false;
iacCommand = -1;
break;
}
} else {
// 3-byte command: IAC <DO/DONT/WILL/WONT> <OPT>
handleOptionCommand(iacCommand, b);
inIAC = false;
iacCommand = -1;
}
} else if (b == IAC) {
inIAC = true;
iacCommand = -1;
} else {
recordBuf.write(b);
}
}
} catch (SocketException e) {
log.fine("Client socket closed: " + e.getMessage());
} catch (Exception e) {
if (running) {
log.log(Level.WARNING, "Error in handleClient", e);
}
} finally {
cleanupClientSocket(s);
}
}
private synchronized void sendInitialNegotiation() throws IOException {
if (clientOut == null) return;
ByteArrayOutputStream out = new ByteArrayOutputStream();
// Server offers DO TN3270E, DO TRANSMIT-BINARY, WILL TRANSMIT-BINARY, DO EOR, WILL EOR
out.write(new byte[] {
(byte) IAC, (byte) DO, (byte) TELOPT_TN3270E,
(byte) IAC, (byte) DO, (byte) TELOPT_BINARY,
(byte) IAC, (byte) WILL, (byte) TELOPT_BINARY,
(byte) IAC, (byte) DO, (byte) TELOPT_EOR,
(byte) IAC, (byte) WILL, (byte) TELOPT_EOR
});
clientOut.write(out.toByteArray());
clientOut.flush();
log.info("Mock3270Server: sent initial negotiation");
}
private void handleOptionCommand(int cmd, int opt) throws IOException {
log.fine("Mock3270Server: RCVD IAC " + cmd + " opt=" + opt);
if (cmd == WILL && opt == TELOPT_TN3270E) {
// Client accepted TN3270E -> Send SB TN3270E SEND DEVICE-TYPE IAC SE
sendDeviceTypeSend();
} else if (cmd == DO && opt == TELOPT_TM) {
// Client requested Timing Mark keep-alive
receivedTimingMarks.incrementAndGet();
log.fine("Mock3270Server: RCVD IAC DO TIMING-MARK");
synchronized (this) {
if (clientOut != null) {
clientOut.write(new byte[] { (byte) IAC, (byte) WILL, (byte) TELOPT_TM });
clientOut.flush();
}
}
}
}
private synchronized void sendDeviceTypeSend() throws IOException {
if (clientOut == null) return;
byte[] devTypeSend = new byte[] {
(byte) IAC, (byte) SB, (byte) TELOPT_TN3270E,
(byte) OP_SEND, (byte) OP_DEVICE_TYPE,
(byte) IAC, (byte) SE
};
clientOut.write(devTypeSend);
clientOut.flush();
log.info("Mock3270Server: sent SB TN3270E SEND DEVICE-TYPE SE");
}
private void handleSubnegotiation(byte[] sb) throws IOException {
if (sb.length < 2 || (sb[0] & 0xFF) != TELOPT_TN3270E) {
return;
}
int op = sb[1] & 0xFF;
if (op == OP_DEVICE_TYPE) {
// Client sent DEVICE-TYPE REQUEST <type> [CONNECT <lu>]
int pos = 2;
if (pos < sb.length && (sb[pos] & 0xFF) == OP_REQUEST) pos++;
StringBuilder reqType = new StringBuilder();
while (pos < sb.length && (sb[pos] & 0xFF) != OP_CONNECT) {
reqType.append((char) (sb[pos] & 0xFF));
pos++;
}
log.info("Mock3270Server: client requested device-type: " + reqType);
// Respond with SB TN3270E DEVICE-TYPE IS <assignedType> CONNECT <assignedLu> IAC SE
sendDeviceTypeIs(assignedDeviceType, assignedLu);
} else if (op == OP_FUNCTIONS) {
// Client sent FUNCTIONS REQUEST <functions...>
List<Integer> funcs = new ArrayList<>();
for (int i = 3; i < sb.length; i++) {
funcs.add(sb[i] & 0xFF);
}
log.info("Mock3270Server: client requested functions: " + funcs);
// Respond with FUNCTIONS IS
sendFunctionsIs();
if (autoSendBind && negotiateBindImage) {
sendBindImage();
}
handshakeLatch.countDown();
}
}
private synchronized void sendDeviceTypeIs(String type, String lu) throws IOException {
if (clientOut == null) return;
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(new byte[] { (byte) IAC, (byte) SB, (byte) TELOPT_TN3270E, (byte) OP_DEVICE_TYPE, (byte) OP_IS });
out.write(type.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
if (lu != null && !lu.isEmpty()) {
out.write(OP_CONNECT);
out.write(lu.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
}
out.write(new byte[] { (byte) IAC, (byte) SE });
clientOut.write(out.toByteArray());
clientOut.flush();
log.info("Mock3270Server: sent SB TN3270E DEVICE-TYPE IS " + type + " CONNECT " + lu + " SE");
}
private synchronized void sendFunctionsIs() throws IOException {
if (clientOut == null) return;
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(new byte[] { (byte) IAC, (byte) SB, (byte) TELOPT_TN3270E, (byte) OP_FUNCTIONS, (byte) OP_IS });
if (negotiateBindImage) {
out.write(FUNC_BIND_IMAGE);
}
out.write(FUNC_RESPONSES);
if (negotiateContentionResolution) {
out.write(FUNC_CONTENTION_RESOLUTION);
}
out.write(FUNC_SYSREQ);
out.write(new byte[] { (byte) IAC, (byte) SE });
clientOut.write(out.toByteArray());
clientOut.flush();
log.info("Mock3270Server: sent SB TN3270E FUNCTIONS IS SE");
}
public synchronized void sendBindImage() throws IOException {
if (clientOut == null) return;
// BIND payload: 5-byte header + standard 26-byte BIND image
byte[] bindPayload = new byte[26];
bindPayload[20] = (byte) primaryRows;
bindPayload[21] = (byte) primaryCols;
bindPayload[22] = (byte) altRows;
bindPayload[23] = (byte) altCols;
bindPayload[24] = (byte) ((altRows == primaryRows && altCols == primaryCols) ? 0x02 : 0x7F);
sendRecord(DT_BIND_IMAGE, 0, RSF_NO_RESPONSE, bindPayload);
log.info("Mock3270Server: sent BIND image (" + primaryRows + "x" + primaryCols + " / " + altRows + "x" + altCols + ")");
}
public synchronized void sendUnbind(int reason) throws IOException {
sendRecord(DT_UNBIND, 0, RSF_NO_RESPONSE, new byte[] { (byte) reason });
log.info("Mock3270Server: sent UNBIND reason=" + reason);
}
public synchronized void sendRecord(int dataType, int requestFlag, int responseFlag, byte[] payload) throws IOException {
if (clientOut == null) throw new IOException("No client connected");
sequenceNumber = (sequenceNumber + 1) & 0xFFFF;
ByteArrayOutputStream out = new ByteArrayOutputStream(payload.length + 16);
// 5-byte TN3270E header
out.write(dataType);
out.write(requestFlag);
out.write(responseFlag);
out.write((sequenceNumber >> 8) & 0xFF);
out.write(sequenceNumber & 0xFF);
// Escape payload IAC (0xFF -> 0xFF 0xFF)
for (byte b : payload) {
int val = b & 0xFF;
out.write(val);
if (val == IAC) {
out.write(IAC);
}
}
// End of Record framing
out.write(IAC);
out.write(EOR);
clientOut.write(out.toByteArray());
clientOut.flush();
}
public synchronized void sendEraseWrite(int wcc, byte[] data, boolean sdi, boolean kri) throws IOException {
ByteArrayOutputStream payload = new ByteArrayOutputStream(data.length + 2);
payload.write(CMD_ERASE_WRITE); // 0xF5
payload.write(wcc);
payload.write(data);
int rqf = (sdi ? RQF_SEND_DATA : 0) | (kri ? RQF_KEYBOARD_RESTORE : 0);
sendRecord(DT_3270_DATA, rqf, RSF_NO_RESPONSE, payload.toByteArray());
}
public synchronized void sendWrite(int wcc, byte[] data, boolean sdi, boolean kri) throws IOException {
ByteArrayOutputStream payload = new ByteArrayOutputStream(data.length + 2);
payload.write(CMD_WRITE); // 0xF1
payload.write(wcc);
payload.write(data);
int rqf = (sdi ? RQF_SEND_DATA : 0) | (kri ? RQF_KEYBOARD_RESTORE : 0);
sendRecord(DT_3270_DATA, rqf, RSF_NO_RESPONSE, payload.toByteArray());
}
public synchronized void sendFormattedScreen(String text, int row, int col, boolean unlockKeyboard) throws IOException {
ByteArrayOutputStream data = new ByteArrayOutputStream();
int addr = row * primaryCols + col;
byte[] sba = DS3270Constants.encodeAddress(addr, primaryRows, primaryCols);
// SBA to target position
data.write(ORDER_SBA);
data.write(sba[0] & 0xFF);
data.write(sba[1] & 0xFF);
// Start Field (unprotected: 0x40 or 0x41)
data.write(ORDER_SF);
data.write(0x40);
// Write EBCDIC text
for (char c : text.toCharArray()) {
data.write(translator.unicodeToEbcdicSafe(c));
}
// Insert Cursor
data.write(ORDER_IC);
// WCC: 0xC3 (sound alarm + unlock keyboard + reset MDT) or 0xC0 (locked)
int wcc = unlockKeyboard ? 0xC3 : 0xC0;
sendEraseWrite(wcc, data.toByteArray(), true, unlockKeyboard);
}
public synchronized void sendFragmented(byte[] rawData, int chunkSize, long delayMs) throws IOException, InterruptedException {
if (clientOut == null) throw new IOException("No client connected");
int offset = 0;
while (offset < rawData.length) {
int len = Math.min(chunkSize, rawData.length - offset);
clientOut.write(rawData, offset, len);
clientOut.flush();
offset += len;
if (delayMs > 0 && offset < rawData.length) {
Thread.sleep(delayMs);
}
}
}
public synchronized void sendRawBytes(byte[] raw) throws IOException {
if (clientOut == null) throw new IOException("No client connected");
clientOut.write(raw);
clientOut.flush();
}
public synchronized void disconnectClient() {
if (currentSocket != null) {
try {
log.info("Mock3270Server: intentionally disconnecting client");
currentSocket.close();
} catch (Exception ignored) {}
cleanupClientSocket(currentSocket);
}
}
private void onRecordReceived(byte[] record) {
log.info("Mock3270Server: received record length=" + record.length);
receivedRecords.add(record);
dataReceivedLatch.countDown();
}
public int getLastReceivedAID() {
byte[] last = getLastReceivedRecord();
if (last == null || last.length <= EH_SIZE) return -1;
return last[EH_SIZE] & 0xFF;
}
public int getLastReceivedCursorAddress() {
byte[] last = getLastReceivedRecord();
if (last == null || last.length < EH_SIZE + 3) return -1;
int b1 = last[EH_SIZE + 1] & 0xFF;
int b2 = last[EH_SIZE + 2] & 0xFF;
return DS3270Constants.decodeAddress(b1, b2);
}
public String getLastReceivedFieldText() {
byte[] last = getLastReceivedRecord();
if (last == null || last.length <= EH_SIZE + 3) return "";
StringBuilder sb = new StringBuilder();
// Skip header + AID (1) + cursor address (2)
int i = EH_SIZE + 3;
while (i < last.length) {
int b = last[i] & 0xFF;
if (b == ORDER_SBA) {
i += 3;
continue;
}
sb.append(translator.ebcdicToUnicode(b));
i++;
}
return sb.toString();
}
private synchronized void cleanupClientSocket(Socket s) {
if (currentSocket == s) {
currentSocket = null;
clientIn = null;
clientOut = null;
}
try {
s.close();
} catch (Exception ignored) {}
}
@Override
public void close() {
running = false;
try {
serverSocket.close();
} catch (Exception ignored) {}
disconnectClient();
executor.shutdownNow();
log.info("Mock3270Server stopped");
}
}
@@ -0,0 +1,293 @@
package haus.nightmare.lib3270j.testutil;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.ConnectionState;
import haus.nightmare.lib3270j.Telnet3270Client;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.ecl.ECLConstants;
import haus.nightmare.lib3270j.ecl.ECLPS;
import haus.nightmare.lib3270j.ecl.ECLScreenDesc;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
import static haus.nightmare.lib3270j.protocol.TN3270EConstants.*;
import static haus.nightmare.lib3270j.protocol.TelnetConstants.*;
import static org.junit.jupiter.api.Assertions.*;
@Timeout(value = 30, unit = TimeUnit.SECONDS)
public class Mock3270ServerIntegrationTest {
private Mock3270Server server;
private Telnet3270Client client;
@BeforeEach
public void setup() throws IOException {
server = new Mock3270Server();
}
@AfterEach
public void teardown() {
if (client != null) {
client.disconnect();
client = null;
}
if (server != null) {
server.close();
server = null;
}
}
private void waitForState(Telnet3270Client cl, ConnectionState targetState, long timeoutMs) throws Exception {
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
if (cl.getConnectionState() == targetState) {
return;
}
Thread.sleep(25);
}
assertEquals(targetState, cl.getConnectionState());
}
@Test
public void testSuccessfulConnectionAndHandshake() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
config.setConnectTimeoutMs(5000);
client = new Telnet3270Client(config);
boolean connected = client.connect(8000);
assertTrue(connected, "Client should establish connected session");
boolean handshakeDone = server.waitForHandshake(5, TimeUnit.SECONDS);
assertTrue(handshakeDone, "Mock server should complete TN3270E handshake");
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
assertEquals("MOCKLU01", client.getTelnetFSM().getConnectedLu());
}
@Test
public void testSendScreenAndVerifyPresentationSpace() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
client = new Telnet3270Client(config);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// Server sends formatted greeting screen at row 2, col 10
String banner = "WELCOME TO TN3270E SERVER";
server.sendFormattedScreen(banner, 2, 10, true);
// Wait up to 3s for screen update
ECLPS ps = client.getPS();
boolean matched = false;
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
String text = ps.getString(2, 11, banner.length());
if (banner.equals(text)) {
matched = true;
break;
}
Thread.sleep(20);
}
assertTrue(matched, "Presentation space should contain banner string");
assertFalse(client.getInputProcessor().isKeyboardLocked(), "Keyboard should be unlocked after WCC restore");
}
@Test
public void testClientUserInputAndAidTransmission() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
client = new Telnet3270Client(config);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// Send screen with unprotected field at row 4, col 10
server.sendFormattedScreen("ENTER DATA: ", 4, 10, true);
Thread.sleep(100);
// Client positions cursor inside unprotected field (col 23) and enters text
client.getPS().setCursorPos(4, 23);
client.sendKeys("TESTDATA[enter]");
// Verify mock server receives AID and field data
assertTrue(server.waitForClientData(5, TimeUnit.SECONDS), "Server must receive client AID submission");
assertEquals(0x7D, server.getLastReceivedAID(), "AID should be ENTER (0x7D)");
assertTrue(server.getLastReceivedFieldText().contains("TESTDATA"),
"Field text should contain submitted characters 'TESTDATA'");
}
@Test
public void testContentionResolutionAndKeyboardLock() throws Exception {
server.setNegotiateContentionResolution(true);
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
client = new Telnet3270Client(config);
client.getTelnetFSM().setNegotiateContentionResolution(true);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// 1. Lock keyboard explicitly (as happens when user presses AID)
client.getInputProcessor().setKeyboardLocked(true);
assertTrue(client.getInputProcessor().isKeyboardLocked());
// Send write with WCC locked (0xC0) and sdi = false
byte[] dummyData = new byte[] { (byte) ORDER_SBA, 0x40, 0x40, (byte) ORDER_SF, 0x60 };
server.sendWrite(0xC0, dummyData, false, false);
Thread.sleep(100);
assertTrue(client.getInputProcessor().isKeyboardLocked(),
"Keyboard must remain locked when SDI is not signaled under contention resolution");
// 2. Unlock via Contention Resolution: send write with SDI flag = true (RQF_SEND_DATA)
server.sendWrite(0xC0, dummyData, true, false);
Thread.sleep(100);
assertFalse(client.getInputProcessor().isKeyboardLocked(),
"Keyboard must unlock upon receiving SDI flag on EOR");
}
@Test
public void testAutoSysUnlockOnEor() throws Exception {
server.setNegotiateContentionResolution(false);
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
config.setAutoSysUnlock(true);
client = new Telnet3270Client(config);
client.getTelnetFSM().setNegotiateContentionResolution(false);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// Lock keyboard initially
client.getInputProcessor().setKeyboardLocked(true);
client.getOIA().setInputInhibited(ECLConstants.INHIBIT_SYSTEM_LOCK);
assertTrue(client.getInputProcessor().isKeyboardLocked());
// Host sends a write record without WCC restore, but autoSysUnlock is active on EOR
byte[] dummyData = new byte[] { (byte) ORDER_SBA, 0x40, 0x40, (byte) ORDER_SF, 0x60 };
server.sendWrite(0xC0, dummyData, false, false);
Thread.sleep(100);
assertFalse(client.getInputProcessor().isKeyboardLocked(),
"autoSysUnlock must unlock keyboard on EOR when not in Read command");
assertFalse(client.getOIA().isXSystem(), "OIA X SYSTEM indicator must be cleared");
}
@Test
public void testAutomatedWaitMethods() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
client = new Telnet3270Client(config);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
ECLScreenDesc desc = new ECLScreenDesc();
desc.addString("LOGIN_READY");
CountDownLatch waitLatch = new CountDownLatch(1);
AtomicBoolean waitResult = new AtomicBoolean(false);
// Run waitForScreen in background thread
CompletableFuture.runAsync(() -> {
boolean res = client.getPS().waitForScreen(desc, 5000);
waitResult.set(res);
waitLatch.countDown();
});
// Delay 80ms then send matching screen from mock server
Thread.sleep(80);
server.sendFormattedScreen("LOGIN_READY", 1, 9, true);
boolean awakened = waitLatch.await(2000, TimeUnit.MILLISECONDS);
assertTrue(awakened, "waitForScreen must wake reactively upon screen update");
assertTrue(waitResult.get(), "waitForScreen must return true when screen matches descriptor");
}
@Test
public void testKeepAliveHeartbeatEngine() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
config.setKeepAliveEnabled(true);
config.setKeepAliveIntervalSeconds(1);
config.setKeepAliveType("NOP");
client = new Telnet3270Client(config);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// Idle for 2.5 seconds to allow keepalive heartbeat to fire
Thread.sleep(2500);
assertTrue(server.getReceivedNops() >= 1,
"Server must have received at least 1 IAC NOP heartbeat, received: " + server.getReceivedNops());
}
@Test
public void testAutoReconnectOnSocketDisconnect() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
config.setAutoReconnect(true);
config.setReconnectMaxRetries(3);
client = new Telnet3270Client(config);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// Disconnect active client connection abruptly
server.disconnectClient();
// Wait for client to detect disconnect and reconnect automatically
waitForState(client, ConnectionState.CONNECTED_TN3270E, 8000);
assertEquals(ConnectionState.CONNECTED_TN3270E, client.getConnectionState());
}
@Test
public void testPacketFragmentation() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
client = new Telnet3270Client(config);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// Build raw 3270 Erase/Write packet with 5-byte header + command + SBA + SF + data + IAC EOR
ByteArrayOutputStream buf = new ByteArrayOutputStream();
// 5-byte header
buf.write(new byte[] { (byte) DT_3270_DATA, 0x00, 0x00, 0x00, 0x01 });
buf.write(CMD_ERASE_WRITE); // 0xF5
buf.write(0xC3); // WCC
buf.write(ORDER_SBA);
buf.write(0x40); buf.write(0x40); // row 0, col 0
buf.write(ORDER_SF);
buf.write(0x40); // unprotected
String text = "FRAGMENTED_TEST";
for (char c : text.toCharArray()) {
buf.write(client.getScreenBuffer().getTranslator().unicodeToEbcdicSafe(c));
}
buf.write((byte) IAC);
buf.write((byte) EOR);
// Send packet in 2-byte fragments with 5ms delays
server.sendFragmented(buf.toByteArray(), 2, 5);
// Verify presentation space renders the entire reconstructed string
boolean matched = false;
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
String read = client.getPS().getString(0, 1, text.length());
if (text.equals(read)) {
matched = true;
break;
}
Thread.sleep(20);
}
assertTrue(matched, "Fragmented stream must be reassembled and displayed correctly");
}
}