comtest.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. #!/usr/bin/env python3
  2. """End-to-end UI test: C writes a real .COM, verified by reading the disk.
  3. uitest.py covers the error path (compile fails -> editor opens on the error).
  4. This covers the success path, which is the one that produces an artifact:
  5. 1. W loads a work file (LoadWorkFile ends with a Pause: needs a filler key)
  6. 2. O opens the Options submenu and C selects Destination = .COM
  7. 3. ESC returns to the main menu
  8. 4. C compiles, and the shell reports the .COM it wrote
  9. 5. The .COM is then INDEPENDENTLY checked on disk: the runtime's first
  10. bytes, a fully zero code/data gap, a size that covers the data area, and
  11. the program header words
  12. Step 5 is the point. "The shell said it wrote a file" is not evidence that
  13. the file is right, and the shell reports success from the same state that
  14. produced the bytes, so the check that matters happens outside the shell.
  15. The .COM is removed afterwards so the test can be re-run.
  16. Usage: comtest.py [fixture.pas]
  17. Passing a fixture that does NOT compile is expected to be RED - that is the
  18. test being non-vacuous, not a failure of the shell. It was checked that way
  19. (t14_types.pas, which raises ENoLib for 'array' at its point of use).
  20. """
  21. import os
  22. import sys
  23. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  24. from ptyharness import SHELL_DIR, Screen, drain, reap, send, spawn, status_str, visible
  25. FIXTURE = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(
  26. os.path.dirname(os.path.abspath(__file__)), "fixtures", "t26_str_mixed_args.pas")
  27. COM = os.path.splitext(FIXTURE)[0] + ".COM"
  28. # Restated here on purpose - the checker must not ask the code under test.
  29. # ENT_SZ and HDR_SZ are the layout, and the load bias below is a decision this
  30. # checker made on its own account; those stay written down.
  31. #
  32. # The RUNTIME'S SIZE is not restated, and that is the correction. It used to
  33. # be a literal (385, then 391) beside a comment saying it tracks
  34. # Runtime.RT_Size(). It did not: the runtime is 432 bytes, so this checker
  35. # read the program header 41 bytes early, out of the middle of the code, and
  36. # reported a .COM full of nonsense - hdrFlag=61579 - as a compiler fault. A
  37. # duplicated constant that has drifted is not an independent check, it is a
  38. # second source of truth that lies, and it lies in a way that looks like the
  39. # thing under test is broken. The size is now MEASURED, by the same
  40. # self-consistent-header argument documented in find_header below.
  41. ENT_SZ = 3 # E9 lo hi, the entry jump
  42. HDR_SZ = 16 # 5 header words + 3 buffer words
  43. # RTSZ, PROLOG and DATAB are derived per .COM from find_header(d).
  44. # The image starts with a three-byte JMP - see Compiler.Inittur. A .COM is
  45. # entered at file offset 0, so before that jump existed this checker ASSERTED
  46. # that the runtime was at offset 0, which was precisely the bug: every .COM
  47. # began by executing initmem with whatever the loader left in AX. A checker
  48. # that pins a wrong invariant is worse than none, because it makes the wrong
  49. # thing look tested.
  50. # The load bias. A DOS .COM's first byte is at CS:0100 and CS = DS, so an
  51. # image offset K is at DS:(K + 0100h); every absolute address the image
  52. # contains has to carry it, or it points 0100h low and - since the code region
  53. # and the runtime are all below the bias - almost always lands inside the
  54. # runtime instead of inside the data. Relative encodings must not carry it.
  55. # Restated, not asked of the code under test. See Runtime.LoadBias.
  56. LOAD_BIAS = 0x100
  57. HEAD = "8B F0 8B 54 04 8B 4C 06" # MOV SI,AX / MOV DX,[SI+4] / MOV CX,[SI+6]
  58. HDR_DS_WORD = 4 # header word holding the data base
  59. HDR_HEAP_WORD = 6 # header word holding the data end
  60. assert [int(HEAD.split()[4], 16), int(HEAD.split()[7], 16)] == \
  61. [HDR_DS_WORD, HDR_HEAP_WORD], \
  62. "initmem no longer reads the two header words this checker verifies"
  63. def find_header(d):
  64. """Return the image offset of the program header, or None.
  65. The header is eight words, and hdrDS ties it to its OWN offset: the data
  66. base is header offset + 1000h, and hdrDS is the data base with the load
  67. bias added, so header offset = hdrDS - 1000h - bias. That makes the
  68. offset recoverable from the file with no remembered runtime size, which is
  69. the whole point - see the note on RT_SZ above.
  70. A candidate is accepted only if hdrFlag is 1, hdrDS satisfies that
  71. equation, hdrHeap is above hdrDS, and hdrCS leaves room for the header
  72. itself. initmem is the only code in the image that reads the header, so
  73. its bytes are pinned at ENT_SZ and a match that also has them is not a
  74. coincidence in the code stream.
  75. Measuring is not the same as asking the compiler: this reads the emitted
  76. file, so it cannot be satisfied by the code under test agreeing with
  77. itself. tests/check_runtime.py is where the runtime's size is pinned on
  78. purpose, and tests/run_com_tests.sh measures and prints it on every run.
  79. """
  80. head = bytes(int(x, 16) for x in HEAD.split())
  81. if d[ENT_SZ:ENT_SZ + len(head)] != head:
  82. return None
  83. for off in range(ENT_SZ, len(d) - HDR_SZ + 1):
  84. w = (lambda b: int.from_bytes(d[off + b:off + b + 2], "little"))
  85. if w(0) != 1:
  86. continue
  87. if w(HDR_DS_WORD) != off + 0x1000 + LOAD_BIAS:
  88. continue
  89. if w(HDR_HEAP_WORD) <= w(HDR_DS_WORD):
  90. continue
  91. if w(2) - LOAD_BIAS < off + HDR_SZ:
  92. continue
  93. return off
  94. return None
  95. def check_com(path, expected_src_len):
  96. """Return a list of problems (empty = the .COM is correct)."""
  97. errs = []
  98. if not os.path.exists(path):
  99. return ["no .COM file was written"]
  100. d = open(path, "rb").read()
  101. # Everything below is in terms of where the header actually is in THIS
  102. # file, measured, rather than where a literal once said it should be.
  103. hdr_off = find_header(d)
  104. if hdr_off is None:
  105. return ["no program header found: the layout this checker knows how to "
  106. "look for is not the one in the file, so every offset below "
  107. "would be a guess - reporting the guesses individually would "
  108. "be worse than saying so once"]
  109. RTSZ = hdr_off
  110. PROLOG = hdr_off + HDR_SZ
  111. DATAB = hdr_off + 0x1000
  112. # The entry jump. This is the only assertion in the project that can see
  113. # where execution STARTS, because it is the only one that cares. It has
  114. # caught three real bugs in these same three bytes: no jump at all; a jump
  115. # to `pc` (the END of the code, in the zero gap); and a jump to RTSZ,
  116. # which is the program HEADER - data, which the CPU then decodes as
  117. # instructions. That last one is why the target is PROLOG and not RTSZ:
  118. # whether it works depends on how the header happens to decode, so
  119. # writeln('hi') slid through it unharmed while t07 hung on a LOCK-prefixed
  120. # ADD. Every other check here reads bytes at an offset the compiler chose
  121. # for itself.
  122. if len(d) >= ENT_SZ:
  123. if d[0] != 0xE9:
  124. errs.append("byte 0 is %02X, not the E9 of the entry jump" % d[0])
  125. want_rel = PROLOG - ENT_SZ
  126. got_rel = int.from_bytes(d[1:ENT_SZ], "little")
  127. if got_rel != want_rel:
  128. errs.append("entry jump rel16=%d, want %d; lands on image offset "
  129. "%d, want %d (the first instruction, %d bytes past the "
  130. "header - not the header at %d)"
  131. % (got_rel, want_rel, ENT_SZ + got_rel, PROLOG,
  132. HDR_SZ, RTSZ))
  133. if d[ENT_SZ:ENT_SZ + len(HEAD.split())].hex(" ").upper() != HEAD:
  134. errs.append("runtime not at offset %d: bytes there %s, want %s"
  135. % (ENT_SZ,
  136. d[ENT_SZ:ENT_SZ + len(HEAD.split())].hex(" ").upper(),
  137. HEAD))
  138. if len(d) < DATAB:
  139. errs.append("file is %d bytes, shorter than the data base %d - the "
  140. "globals would be outside the file" % (len(d), DATAB))
  141. # the runtime must be followed immediately by the program header,
  142. # whose first word is the "image is valid" flag
  143. if len(d) >= RTSZ + 2:
  144. flag = int.from_bytes(d[RTSZ:RTSZ + 2], "little")
  145. if flag != 1:
  146. errs.append("hdrFlag=%d at the program header, want 1" % flag)
  147. if len(d) >= RTSZ + 10:
  148. ds = int.from_bytes(d[RTSZ + HDR_DS_WORD:RTSZ + HDR_DS_WORD + 2], "little")
  149. heap = int.from_bytes(d[RTSZ + HDR_HEAP_WORD:RTSZ + HDR_HEAP_WORD + 2], "little")
  150. if ds != DATAB + LOAD_BIAS:
  151. errs.append("hdrDS=%d, want %d (= data base %d + load bias %d)"
  152. % (ds, DATAB + LOAD_BIAS, DATAB, LOAD_BIAS))
  153. if heap < ds:
  154. errs.append("hdrHeap=%d < hdrDS=%d" % (heap, ds))
  155. if [d[ENT_SZ + 4], d[ENT_SZ + 7]] != [HDR_DS_WORD, HDR_HEAP_WORD]:
  156. errs.append("initmem reads header words +%d/+%d, but the data base "
  157. "and data end are at +%d/+%d"
  158. % (d[ENT_SZ + 4], d[ENT_SZ + 7],
  159. HDR_DS_WORD, HDR_HEAP_WORD))
  160. # The gap between the end of the code and the data area must be all zero.
  161. # hdrCS is a SEGMENT offset, like every other offset in the header, so
  162. # LOAD_BIAS comes back off before it is used to index the file.
  163. cs = (int.from_bytes(d[RTSZ + 2:RTSZ + 4], "little") - LOAD_BIAS) \
  164. if len(d) >= RTSZ + 4 else 0
  165. if cs and cs < DATAB:
  166. nz = sum(1 for b in d[cs:DATAB] if b)
  167. if nz:
  168. errs.append("%d non-zero bytes in the code/data gap" % nz)
  169. return errs
  170. def main():
  171. if os.path.exists(COM):
  172. os.unlink(COM)
  173. pid, fd = spawn()
  174. checks = []
  175. try:
  176. drain(fd, quiet=0.6)
  177. send(fd, b"w")
  178. send(fd, FIXTURE.encode())
  179. send(fd, b"\r", quiet=0.8)
  180. send(fd, b" ", quiet=0.5) # clears LoadWorkFile's Pause
  181. # Options submenu -> Destination = .COM.
  182. # The submenu is left with Q (that is its Quit key, as in the
  183. # original's optmenu); ESC is swallowed by its REPEAT loop.
  184. send(fd, b"o", quiet=0.8)
  185. opt = visible(send(fd, b"c", quiet=0.5))
  186. checks.append(("Options submenu opened", "Compile" in opt))
  187. checks.append(("selecting C set Destination = COM", "COM" in opt))
  188. after = visible(send(fd, b"q", quiet=0.8))
  189. checks.append(("Q left the submenu for the main menu", "Main file" in after))
  190. comp = visible(send(fd, b"c", quiet=2.0))
  191. checks.append(("compile reported success", "Compiled OK" in comp))
  192. checks.append(("shell reported the Destination it used",
  193. "Destination" in comp))
  194. checks.append(("shell named the .COM it wrote",
  195. os.path.basename(COM) in comp))
  196. send(fd, b"\x1b", quiet=0.6)
  197. send(fd, b"q", quiet=0.8)
  198. finally:
  199. status = reap(fd, pid)
  200. checks.append(("shell exited cleanly (status 0)", os.WIFEXITED(status)
  201. and os.WEXITSTATUS(status) == 0))
  202. # now verify the artifact independently
  203. errs = check_com(COM, len(open(FIXTURE, "rb").read()))
  204. checks.append((".COM exists on disk and passes the independent check"
  205. if not errs else ".COM check: " + "; ".join(errs), not errs))
  206. checks.append((".COM was written next to the source, not in the shell's cwd",
  207. os.path.dirname(os.path.abspath(COM)) == os.path.dirname(FIXTURE)))
  208. print("UI TEST: %s" % os.path.basename(FIXTURE))
  209. print("-" * 60)
  210. ok = True
  211. for name, passed in checks:
  212. print("%-58s %s" % (name[:58], "PASS" if passed else "FAIL"))
  213. ok = ok and passed
  214. print("-" * 60)
  215. print("child: %s" % status_str(status))
  216. print("RESULT: %s" % ("ALL PASS" if ok else "FAILURES PRESENT"))
  217. if os.path.exists(COM):
  218. print("(removed %s)" % COM)
  219. os.unlink(COM)
  220. return 0 if ok else 1
  221. if __name__ == "__main__":
  222. sys.exit(main())