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
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
+293
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user