Skip to content

Commit 250dbb7

Browse files
committed
test(apex): submit an imported page and watch the exported rules fire
The verification chain could show that an approved rule was in the APEX dictionary, and that its code raised inside an apex_session. It could not show the thing the whole conversion rests on: that APEX's own page processing runs the rule when a person presses a button, and puts the message where the export said to put it. That gap is why the acceptance notes have carried "the browser submit has not been observed" since the first import. apex_submit_check.py closes it, reusing the render check's disposable end user rather than anyone's account. It fetches the page, posts it back to wwv_flow.accept the way the browser does, and reads what came back: the message rendered beside each field, and the messages in the notification region. It submits twice, once with the item under test empty and once with a valid value, because a page that rejects everything looks exactly like a page whose rules work until you try the second case. Two details cost an hour each, and both answer with a protection violation that names nothing: pPageItemsProtected is one base64 string split into ".,"-separated chunks that continue each other, so decoding chunk by chunk truncates a name at every boundary; and a protected item has to be submitted carrying its own checksum, read from its companion input, rather than left out. On application 190122 all six exported validations fired with VL_PRECO empty -- the four item rules inline beside their fields, the two record rules in the notification, which is what their display locations said -- and 19.90 removed that rule's message while the other five stayed. The acceptance notes now record the render and the submit, and say what is still untested: nothing was saved, no rule outside the six was exercised, and no layout was compared against Forms.
1 parent e8fe949 commit 250dbb7

3 files changed

Lines changed: 351 additions & 0 deletions

File tree

docs/apex-import-verification.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,34 @@ To do the same by hand, run the two PL/SQL blocks the script contains in
153153
SQLcl, log in with a browser as that user, save the page from the browser,
154154
and remove the user afterwards.
155155

156+
### The submit
157+
158+
A GET proves the page renders; it does not show a rule firing. The second
159+
half of the step does that, with the same temporary user and the same login:
160+
161+
```
162+
python examples/verify/apex_submit_check.py out/render \
163+
--app 190122 --page 1 --item P1_VL_PRECO --good 19.90
164+
```
165+
166+
It fetches the page, posts it back to `wwv_flow.accept` the way the browser
167+
does — `p_json` carrying every page item — and prints, per case, the messages
168+
APEX rendered inline beside each field and in the notification region. It
169+
submits twice: once with the item under test empty, where the rule must
170+
reject, and once with `--good` in it, where that same rule must be gone. The
171+
second submit is the control; without it a page that rejects everything looks
172+
like a page whose rules work.
173+
174+
Two gotchas are already paid for, and both answer with a protection violation
175+
rather than anything that names the cause:
176+
177+
- `pPageItemsProtected` is one base64 string **split into `.,`-separated
178+
chunks that continue each other** (`P1_TOKEN_SESS` + `AO`). Decode the
179+
chunks separately and you get truncated names.
180+
- A protected item must be submitted **with its own checksum**, the `value` of
181+
its companion `<input data-for="ITEM">`, as `ck` beside `n` and `v`.
182+
Without it APEX answers *Page protection violation*.
183+
156184
## 5. Read the actual HTML
157185

158186
Check the saved `render<app>_p<page>.html` for:

docs/quality-acceptance.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,99 @@ FormsLang_1.2.2_x64_en-US.msi
5757
8E1F44C4057E9315041E1C4692DF4E125745AF9A6E0327F3AD41254F6069ED6E
5858
```
5959

60+
## Import verification on `main` (2026-09-08)
61+
62+
The first execution of [APEX verification](apex-import-verification.md) on any
63+
machine. Engine at commit `e8fe949`; `9327d87` on top changes documentation
64+
only. Target: Oracle AI Database 26ai Free 23.26.3.0.0, APEX 26.1.0, SQLcl
65+
26.2.2.0, ORDS on `localhost:8080`, local workspace `FORMSLANG`, disposable
66+
application 190122. No customer artefact was involved. The import and
67+
dictionary steps used the workspace's single existing account; the render and
68+
submit steps each created one temporary end user and removed it in a
69+
`finally`.
70+
71+
Source: `tests/fixtures/showcase/module.xml`, its six `WHEN-VALIDATE-ITEM` and
72+
`WHEN-VALIDATE-RECORD` units approved with a `raise_application_error` body,
73+
exported through `export_apexlang`. Package SHA-256
74+
`a9241052afc885f8e074673af8557ddc915e11ab5c0540e5eb789cd59b17de8b`.
75+
76+
Steps 1 to 5 passed and step 6 has not been run. The authenticated render and
77+
the browser submit were completed later the same day, against the same
78+
application, and are recorded below.
79+
80+
- **Import (step 2).** `formslang apex import` reported `Importing application
81+
ID: 190122 into workspace: FORMSLANG`, `Import successful.` and `Result: OK`,
82+
with no `APEXlang Compile Errors`.
83+
- **Dictionary (step 3).** Application 190122 holds 3 pages, 42 items, 32
84+
regions, 4 processes and **6 validations**. All six are of type `PL/SQL
85+
Error`, sequenced 10 to 60, with no condition and no button restriction —
86+
that is, enabled and evaluated on every submit. The four item-level rules
87+
carry the expected `associated_item` (`P1_VL_PRECO`, `P1_FK_CATEGORIA`,
88+
`P1_CD_BARRA`, `P1_FK_FORNECEDOR`) and display
89+
`INLINE_WITH_FIELD_AND_NOTIFICATION`; the two record-level rules carry no
90+
item and display `INLINE_IN_NOTIFICATION`. The stored code shows the page
91+
prefix rewritten from `:P0_` to `:P1_`.
92+
- **Label span (step 3).** The `grid_label_column_span >=
93+
nvl(grid_column_span, 12)` query returned no rows.
94+
- **Rule execution (step 5, in the database).** In a session opened with
95+
`apex_session.create_session(190122, 1, 'FORMSLANG')`, the code as stored in
96+
`apex_application_page_val` for `P1_VL_PRECO` was executed twice through
97+
`apex_exec.execute_plsql`. With the item empty it raised `ORA-20001:
98+
VL_PRECO e obrigatorio (regra vinda do Forms).`; with the item set to
99+
`19.90` it completed silently. The rule that came from the Forms trigger
100+
rejects and accepts, in APEX, the same values it rejected and accepted in
101+
Forms.
102+
- **Render (step 4).** ORDS answered both `f?p=190122:1` and
103+
`/ords/r/formslang/formslang-a4/` with HTTP 200 and served the application's
104+
own login page (`flow: 190122`, no `ORA-` and no `ERR-`); the authentication
105+
scheme is `Oracle APEX Accounts`.
106+
`examples/verify/apex_render_check.py` then created a temporary end user in
107+
the workspace, logged in through `wwv_flow.accept` the way the browser does,
108+
fetched page 1 with the session cookie and removed the user again. The page
109+
came back **HTTP 200, 88,885 bytes** (`render190122_p1.html`): nine
110+
`t-Region`s, three Interactive Grids with their headings in Forms order, 37
111+
form fields, 3 Date Pickers, 15 Number Fields and two textareas, with **no**
112+
error banner, no `LABEL_COLUMN_SPAN` error, no `ORA-`, no absolute
113+
positioning and no layout script of FormsLang's own.
114+
- **Browser submit (step 4, continued).**
115+
`examples/verify/apex_submit_check.py` posted that page back to
116+
`wwv_flow.accept` on the same session, the way the browser's own submit does
117+
`p_json` carrying every page item, each protected item with its own
118+
checksum. With `P1_VL_PRECO` empty, APEX's page-processing engine ran the
119+
exported validations and **rejected the submit**: the four item-level rules
120+
rendered **inline beside their fields**`<div id="P1_VL_PRECO_error">`
121+
inside the item's `t-Form-error`, with `aria-invalid="true"`,
122+
`aria-describedby` and `apex-page-item-error` on the input — and again in
123+
the notification region; the two record-level rules rendered in the
124+
notification only. That is `inlineWithFieldAndInNotification` and
125+
`inlineInNotification` behaving on screen as they were exported.
126+
Submitting the same page with `P1_VL_PRECO = 19.90` removed that rule's
127+
message from both places and left the other five — the negative control.
128+
The same run against `P1_CD_BARRA` with a valid EAN-13 (`7891234567895`)
129+
behaved identically. Saved as `submit190122_p1_empty.html` and
130+
`submit190122_p1_valid.html`.
131+
- **What the user reads is the validation's own message.** The text on screen
132+
is the validation's `errorMessage`, which FormsLang emits as a placeholder
133+
(`Forms validation <unit> failed. Replace this text with the message your
134+
users should see.`, `formslang/apexlang.py`), **not** the message inside the
135+
rule's `raise_application_error`. A converted application therefore shows
136+
placeholder text to end users until each validation's message is reworded.
137+
Observed, not inferred.
138+
139+
What this does not prove:
140+
141+
- Anything about save behaviour. Nothing was written to a table: the page has
142+
no bound form region, so there is no fetch and no DML to exercise.
143+
- Anything about a rule outside the six approved for this exercise, or about a
144+
module other than the synthetic showcase.
145+
- Anything about tab order or layout fidelity against the Forms runtime. The
146+
page renders cleanly; it was not compared field by field with Forms.
147+
148+
Application 190122 remains installed in workspace `FORMSLANG`. It is
149+
disposable and may be deleted; step 6 is the only step still open. The
150+
temporary end user both scripts create is removed in a `finally`, so no test
151+
account survives a run — including a failed one.
152+
60153
## Acceptance layers
61154

62155
| Layer | Required evidence | Boundary |
@@ -66,6 +159,7 @@ FormsLang_1.2.2_x64_en-US.msi
66159
| Failure recovery | Cancellation, failed providers, saved progress and crashed-job reconciliation | Existing store, workbench and AI tests; not a power-loss certification |
67160
| Desktop usability | Reachable header actions at 1100, 1280 and 1380 px; review, reload, Doc, Preview and export | Browser checks plus packaged-engine smoke; native installer acceptance is separate |
68161
| Reproducible export | Same session and configuration produce byte-identical ZIPs | Does not prove imported pages render or behave correctly |
162+
| Import, render and rule execution | Package imports into a disposable application with no compile errors; the APEX dictionary matches what was exported; the page renders with no error banner; a real browser submit runs the exported validations, shows the item-level ones inline beside their fields, and a valid value clears the message | Done once, 2026-09-08, for six validations on one page; no DML was exercised, and no rule outside those six |
69163
| Real migration | Approved private corpus, Forms runtime reference, APEX render and functional comparison | Pending; synthetic showcase is not a production corpus |
70164

71165
## Upgrade and recovery
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""Submit an imported page the way the browser does, and read what came back.
2+
3+
``apex_render_check.py`` proves the page renders. This proves the last thing
4+
the chain never showed: that APEX's page-processing engine runs the
5+
validations FormsLang exported from Forms triggers, and where the message
6+
lands on the screen.
7+
8+
It logs in as the same disposable end user, GETs the page, and POSTs
9+
``wwv_flow.accept`` twice:
10+
11+
1. with the item under test empty -- the rule must reject the submit;
12+
2. with a valid value in it -- that same rule must not appear.
13+
14+
Both responses are saved. Nothing is written to the application.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import argparse
20+
import base64
21+
import html as htmlmod
22+
import json
23+
import re
24+
import secrets as pysecrets
25+
import urllib.parse
26+
import urllib.request
27+
from collections import OrderedDict
28+
from http.cookiejar import CookieJar
29+
from pathlib import Path
30+
31+
# Same directory, same disposable user, same login: this is the second half of
32+
# the render check, not a separate harness.
33+
import apex_render_check as rc
34+
35+
ITEM_RE = re.compile(
36+
r'<(input|select|textarea)\b(?P<attrs>[^>]*)>', re.IGNORECASE
37+
)
38+
ATTR_RE = re.compile(r'(\w[\w-]*)\s*=\s*"([^"]*)"')
39+
40+
41+
def form_items(page: str, page_no: int) -> OrderedDict[str, str]:
42+
"""Every named page item on the form, with the value the page rendered."""
43+
items: OrderedDict[str, str] = OrderedDict()
44+
prefix = f"P{page_no}_"
45+
for m in ITEM_RE.finditer(page):
46+
attrs = dict(ATTR_RE.findall(m.group("attrs")))
47+
name = attrs.get("name", "")
48+
if not name.startswith(prefix):
49+
continue
50+
# A checkbox or radio that is not checked submits nothing.
51+
kind = attrs.get("type", "").lower()
52+
if kind in {"checkbox", "radio"} and "checked" not in m.group("attrs").lower():
53+
items.setdefault(name, "")
54+
continue
55+
items[name] = htmlmod.unescape(attrs.get("value", ""))
56+
return items
57+
58+
59+
def item_checksums(page: str) -> dict[str, str]:
60+
"""item -> checksum, from the companion ``<input data-for="ITEM">``."""
61+
out: dict[str, str] = {}
62+
for m in ITEM_RE.finditer(page):
63+
attrs = dict(ATTR_RE.findall(m.group("attrs")))
64+
target = attrs.get("data-for")
65+
if target and "value" in attrs:
66+
out[target] = htmlmod.unescape(attrs["value"])
67+
return out
68+
69+
70+
def protected_names(protected: str) -> set[str]:
71+
"""The item names APEX packed into pPageItemsProtected, for exclusion."""
72+
# APEX breaks the base64 into ".,"-separated chunks that continue each
73+
# other: decoding them one by one truncates the name on every boundary
74+
# (P1_TOKEN_SESS + AO). Join first, then decode once.
75+
blob = protected.split("/")[0].replace(".,", "")
76+
pad = "=" * (-len(blob) % 4)
77+
try:
78+
text = base64.b64decode(blob + pad).decode("utf-8", "replace")
79+
except Exception: # noqa: BLE001 - an undecodable blob tells us nothing
80+
return set()
81+
return {n for n in text.split(":") if n}
82+
83+
84+
def inline_errors(page: str) -> list[dict]:
85+
"""Field-level messages, as APEX renders them beside the item."""
86+
# Universal Theme renders the field-level message as
87+
# <div class="t-Form-error"><div id="ITEM_error">text</div></div>, inside
88+
# the item's own container -- a <span> only in older templates.
89+
out = []
90+
for m in re.finditer(
91+
r'<(?:div|span)[^>]*\bid="(P\d+_[A-Z0-9_]+)_error"[^>]*>(.*?)</(?:div|span)>',
92+
page,
93+
re.DOTALL,
94+
):
95+
text = re.sub(r"<[^>]+>", "", m.group(2)).strip()
96+
if text:
97+
out.append({"item": m.group(1), "message": htmlmod.unescape(text)})
98+
return out
99+
100+
101+
def notification_errors(page: str) -> list[str]:
102+
"""Messages in the page-level notification region."""
103+
out = []
104+
for m in re.finditer(
105+
r'<li[^>]*class="[^"]*a-Notification-item[^"]*"[^>]*>(.*?)</li>',
106+
page,
107+
re.DOTALL,
108+
):
109+
text = re.sub(r"<[^>]+>", " ", m.group(1))
110+
text = htmlmod.unescape(re.sub(r"\s+", " ", text)).strip()
111+
if text:
112+
out.append(text)
113+
if not out:
114+
banner = r'<div[^>]*t-Alert--danger.*?</div>\s*</div>\s*</div>'
115+
for m in re.finditer(banner, page, re.DOTALL):
116+
text = re.sub(r"<[^>]+>", " ", m.group(0))
117+
text = htmlmod.unescape(re.sub(r"\s+", " ", text)).strip()
118+
if text:
119+
out.append(text[:400])
120+
return out
121+
122+
123+
def submit(opener, ords: str, app: int, page_no: int, page_html: str,
124+
overrides: dict[str, str], request: str) -> str:
125+
"""POST the page exactly as the browser's own submit does."""
126+
fields = {
127+
k: rc.hidden_value(page_html, k)
128+
for k in ("pFlowId", "pFlowStepId", "pInstance", "pPageSubmissionId",
129+
"pSalt", "pPageItemsProtected", "pPageItemsRowVersion")
130+
}
131+
items = form_items(page_html, page_no)
132+
items.update(overrides)
133+
# A protected item travels with its own checksum -- the browser reads it
134+
# from the companion <input data-for="ITEM" value="CK">. Submitting the
135+
# item without its ck is a page protection violation.
136+
checksums = item_checksums(page_html)
137+
to_submit = []
138+
for name, value in items.items():
139+
entry = {"n": name, "v": value}
140+
if name in checksums:
141+
entry["ck"] = checksums[name]
142+
to_submit.append(entry)
143+
p_json = json.dumps({
144+
"pageItems": {
145+
"itemsToSubmit": to_submit,
146+
"protected": fields["pPageItemsProtected"],
147+
"rowVersion": fields["pPageItemsRowVersion"],
148+
"formRegionChecksums": [],
149+
},
150+
"salt": fields["pSalt"],
151+
})
152+
body = urllib.parse.urlencode({
153+
"p_flow_id": fields["pFlowId"] or str(app),
154+
"p_flow_step_id": fields["pFlowStepId"] or str(page_no),
155+
"p_instance": fields["pInstance"],
156+
"p_page_submission_id": fields["pPageSubmissionId"],
157+
"p_request": request,
158+
"p_reload_on_submit": "S",
159+
"p_json": p_json,
160+
}).encode()
161+
req = urllib.request.Request(
162+
f"{ords}/wwv_flow.accept",
163+
data=body,
164+
headers={"Content-Type": "application/x-www-form-urlencoded",
165+
"Referer": f"{ords}/f?p={app}:{page_no}"},
166+
)
167+
with opener.open(req, timeout=120) as r:
168+
return r.read().decode("utf-8", "replace")
169+
170+
171+
def run(args, pwd: str) -> list[dict]:
172+
jar = CookieJar()
173+
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
174+
opener.addheaders = [("User-Agent", "Mozilla/5.0 (FormsLang verification)")]
175+
session = rc.login(opener, args.ords, args.app, args.login_page, pwd)
176+
if not session:
177+
return [{"ok": False, "why": "login not accepted"}]
178+
179+
results = []
180+
for label, overrides in (
181+
("empty", {args.item: ""}),
182+
("valid", {args.item: args.good}),
183+
):
184+
with opener.open(f"{args.ords}/f?p={args.app}:{args.page}:{session}", timeout=120) as r:
185+
page_html = r.read().decode("utf-8", "replace")
186+
answer = submit(opener, args.ords, args.app, args.page, page_html,
187+
overrides, args.request)
188+
out = args.out_dir / f"submit{args.app}_p{args.page}_{label}.html"
189+
out.write_text(answer, encoding="utf-8")
190+
results.append({
191+
"case": label,
192+
f"{args.item}": overrides[args.item],
193+
"file": str(out),
194+
"bytes": len(answer),
195+
"inline_errors": inline_errors(answer),
196+
"notification_errors": notification_errors(answer),
197+
"ora_20001": sorted(set(re.findall(r"ORA-20001[^<\"\\n]{0,120}", answer))),
198+
"error_banner": "t-Alert--danger" in answer,
199+
})
200+
return results
201+
202+
203+
def main() -> None:
204+
p = argparse.ArgumentParser(description=__doc__)
205+
p.add_argument("out_dir", type=Path)
206+
p.add_argument("--app", type=int, default=190122)
207+
p.add_argument("--page", type=int, default=1)
208+
p.add_argument("--item", default="P1_VL_PRECO")
209+
p.add_argument("--good", default="19.90")
210+
p.add_argument("--request", default="SAVE")
211+
p.add_argument("--ords", default="http://localhost:8080/ords")
212+
p.add_argument("--workspace", default="FORMSLANG")
213+
p.add_argument("--schema", default="FORMSLANG")
214+
p.add_argument("--login-page", type=int, default=9999)
215+
args = p.parse_args()
216+
args.out_dir.mkdir(parents=True, exist_ok=True)
217+
218+
sql = rc.SqlRunner()
219+
pwd = "Fl" + pysecrets.token_urlsafe(18) + "9!"
220+
print(rc.create_user(sql, args.workspace, args.schema, pwd))
221+
try:
222+
results = run(args, pwd)
223+
finally:
224+
print(rc.remove_user(sql, args.workspace))
225+
print(json.dumps(results, indent=1, ensure_ascii=False))
226+
227+
228+
if __name__ == "__main__":
229+
main()

0 commit comments

Comments
 (0)