Nick's real location list hit the production import and got 'Internal Server Error' with no line number - BL-027's class again, three days after the migration outage: Postgres enforces VARCHAR lengths and refuses control bytes, SQLite shrugs at both, and the importers were only ever rehearsed on SQLite. Reproduced both hazards locally (an over-long value and a NUL byte import cleanly on SQLite; either 500s Postgres wholesale). Both importers now validate per row, before any INSERT, so every dialect answers the same way - with the line number and a reason: - locations: control characters; names over 200; codes over 60; combined paths over 200 (checked where the path exists, with read-counts taken before the loop so a mid-loop rejection is not counted twice) - materials: control characters; description/unit/code over 300/20/80 And the client stops lying about it: wp-list-import.js read every response with r.json(), so a plain-text 500 threw mid-parse and surfaced as 'Could not reach the server' while the server was answering fine. One tolerant reader (text -> parse if it parses -> keep status) now serves import, add and patch; a real error reads 'Import refused - HTTP 500'. Pins: materials_check +2 (over-long and control-byte rows reject at line, 20/20), locations_check +1 (over-long name rejects at line, 59/59). Items: CR-005, D6, BL-027 (second instance of its class; the probe-side dialect guard it proposes is still open). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
458 lines
23 KiB
Python
458 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""Per-project Building / Floor / Sector taxonomy — CR-005 (T5.4).
|
|
|
|
CLAUDE.md names this as one of the change requests that gets "silently half-built
|
|
if you treat it as frontend-only": it needs structured location storage and
|
|
aggregate endpoints, not localStorage. So the API is tested directly as well as
|
|
through the wizard, and one check greps the wizard's own code for a localStorage
|
|
write behind the list.
|
|
|
|
1. CSV upload and paste both work, and report rejected rows WITH reasons
|
|
2. duplicates are detected and reported, not silently merged
|
|
3. values are editable after import — rename and add
|
|
4. deactivating hides a value from new work packages; a package already
|
|
referencing it still resolves
|
|
5. values are stored as codes suitable for grouping, and renaming does not
|
|
move the code
|
|
6. no guessed real-world floor name exists anywhere in the code
|
|
|
|
Check 5 is the one with teeth. `CR-018` rolls cost up by these values, so the
|
|
grouping key has to survive a rename — the probe renames a node and demands its
|
|
path is byte-identical afterwards.
|
|
|
|
Exit 0 all passed, 1 a failure, 2 could not run.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
import cdp # noqa: E402
|
|
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
STUB = """
|
|
window.__dialogs = [];
|
|
window.alert = function (m) { window.__dialogs.push(['alert', String(m)]); };
|
|
window.confirm = function (m) { window.__dialogs.push(['confirm', String(m)]); return false; };
|
|
window.prompt = function (m) { window.__dialogs.push(['prompt', String(m)]); return null; };
|
|
true
|
|
"""
|
|
|
|
# Deliberately not a real building. IMPLEMENTATION.md section 8: the B100 floor
|
|
# and area list has not been supplied, and a probe that invented one would put a
|
|
# guessed Micron floor name in the repository just as surely as the app would.
|
|
GOOD = "\n".join([
|
|
"Building,Floor,Sector",
|
|
"Probe Building One,Probe Level 1,Probe Sector A",
|
|
"Probe Building One,Probe Level 1,Probe Sector B",
|
|
"Probe Building One,Probe Level 2,Probe Sector A",
|
|
"Probe Building Two,Probe Level 1,Probe Sector A",
|
|
])
|
|
|
|
MESSY = "\n".join([
|
|
"Probe Building One,Probe Level 1,Probe Sector A", # duplicate of the above
|
|
",Probe Level 9,Probe Sector Z", # no building
|
|
"Probe Building Three,,Probe Sector Q", # sector with no floor
|
|
"Probe Building Three,Probe Level 1,Probe Sector A,too many",
|
|
"---,Probe Level 1", # no code can be made
|
|
"Probe Building Three,Probe Level 1,Probe Sector A", # genuinely new
|
|
"Probe Building Three,Probe Level 1,Probe Sector A", # duplicate within the file
|
|
])
|
|
|
|
|
|
def settle(seconds=1.4):
|
|
time.sleep(seconds)
|
|
|
|
|
|
def api(page, method, path, body=None):
|
|
"""Call the API from inside the page, so the session cookie rides along."""
|
|
js = """(() => {
|
|
const opts = {method: %s, headers: {'Accept':'application/json'}};
|
|
%s
|
|
return fetch(%s, opts).then(r => r.text().then(t => JSON.stringify(
|
|
{status: r.status, body: (() => { try { return JSON.parse(t); } catch (e) { return t; } })()})));
|
|
})()""" % (
|
|
json.dumps(method),
|
|
("opts.headers['Content-Type']='application/json'; opts.body=%s;" % json.dumps(json.dumps(body)))
|
|
if body is not None else "",
|
|
json.dumps(path),
|
|
)
|
|
return json.loads(page.eval(js))
|
|
|
|
|
|
def open_step11(page, base, tok, project="projA"):
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.goto(base + "/work-package-suite.html?project=%s&step=11" % project)
|
|
settle(2.0)
|
|
page.eval(STUB)
|
|
|
|
|
|
def rows_in_list(page):
|
|
return json.loads(page.eval("""JSON.stringify(
|
|
[...document.querySelectorAll('#loc-list .loc-row')].map(r => ({
|
|
level: (r.querySelector('.loc-level')||{}).textContent,
|
|
name: (r.querySelector('.loc-name')||{}).value,
|
|
code: (r.querySelector('.loc-code')||{}).textContent,
|
|
active: !!(r.querySelector('.loc-active')||{}).checked,
|
|
off: r.classList.contains('is-off'),
|
|
})))"""))
|
|
|
|
|
|
def run(page, base, tok):
|
|
print("\n1. paste imports, and reports what it refused")
|
|
open_step11(page, base, tok)
|
|
chk("the wizard has an eleventh step and it is showing",
|
|
page.eval("getComputedStyle(document.getElementById('sop-step-11')).display") == "block",
|
|
page.eval("(document.getElementById('sop-step-11')||{}).id"))
|
|
chk("...named Locations in the rail",
|
|
page.eval("(document.querySelector('#step-rail-list .step-btn[data-step=\"11\"] "
|
|
".step-btn-label')||{}).textContent") == "Locations")
|
|
# It was the last step when T5.4 shipped; T5.5 appended Sections after it. The
|
|
# thing that matters either way is that the wizard's own navigation follows —
|
|
# a step whose Next button is missing is a dead end, whichever number it has.
|
|
chk("...and the wizard's navigation follows it",
|
|
page.eval("getComputedStyle(document.getElementById('sop-next-btn')).display")
|
|
!= "none")
|
|
chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
|
|
|
|
page.eval("document.getElementById('loc-paste').value = %s" % json.dumps(GOOD))
|
|
page.eval("document.getElementById('loc-check-btn').click()")
|
|
settle(1.2)
|
|
report = page.eval("(document.getElementById('loc-report')||{}).textContent||''")
|
|
# Four, not five: the header line is recognised and skipped. Asserted at the
|
|
# number rather than "greater than zero", because "read 4 of a 5-line file" is
|
|
# exactly the kind of quiet loss this report exists to make visible.
|
|
chk("checking without importing reads every data row, header skipped",
|
|
"4 rows read" in report, repr(report[:120]))
|
|
chk("...and says what it would create without creating it",
|
|
"9 values would be added" in report, repr(report[:120]))
|
|
chk("...and writes nothing", api(page, "GET", "/api/projects/projA/locations")
|
|
["body"]["nodes"] == [], api(page, "GET", "/api/projects/projA/locations")["body"])
|
|
|
|
page.eval("document.getElementById('loc-import-btn').click()")
|
|
settle(1.6)
|
|
listed = rows_in_list(page)
|
|
chk("importing creates the whole hierarchy, parents included", len(listed) == 9,
|
|
[r["code"] for r in listed])
|
|
chk("...two buildings", sum(1 for r in listed if r["level"] == "building") == 2,
|
|
[r["code"] for r in listed])
|
|
chk("...three floors", sum(1 for r in listed if r["level"] == "floor") == 3,
|
|
[r["code"] for r in listed])
|
|
chk("...four sectors", sum(1 for r in listed if r["level"] == "sector") == 4,
|
|
[r["code"] for r in listed])
|
|
chk("...and a shared parent is created once, not once per row",
|
|
[r["code"] for r in listed].count("PROBE-BUILDING-ONE") == 1,
|
|
[r["code"] for r in listed])
|
|
|
|
print("\n5. the values are codes, and a rename does not move one")
|
|
codes = [r["code"] for r in listed]
|
|
chk("every value carries a slug path, not a display string",
|
|
all(re.fullmatch(r"[A-Z0-9-]+(/[A-Z0-9-]+)*", c) for c in codes), codes)
|
|
chk("...with the hierarchy in the path, which is what CR-018 groups by",
|
|
"PROBE-BUILDING-ONE/PROBE-LEVEL-1/PROBE-SECTOR-A" in codes, codes)
|
|
nodes = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
|
target = [n for n in nodes if n["path"] == "PROBE-BUILDING-ONE/PROBE-LEVEL-2"][0]
|
|
renamed = api(page, "PATCH", "/api/projects/projA/locations/" + target["id"],
|
|
{"name": "Renamed Level"})
|
|
chk("renaming a value succeeds", renamed["status"] == 200, renamed)
|
|
chk("...changes the label", renamed["body"]["name"] == "Renamed Level", renamed["body"])
|
|
chk("...and leaves the code EXACTLY as it was",
|
|
renamed["body"]["path"] == target["path"] and renamed["body"]["code"] == target["code"],
|
|
[target["path"], renamed["body"]["path"]])
|
|
kids = [n for n in api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
|
if n["path"].startswith(target["path"] + "/")]
|
|
chk("...and its children's codes with it, so nothing beneath is orphaned",
|
|
kids and all(k["path"].startswith("PROBE-BUILDING-ONE/PROBE-LEVEL-2/") for k in kids),
|
|
[k["path"] for k in kids])
|
|
|
|
print("\n2. duplicates are reported, not merged")
|
|
page.eval("document.getElementById('loc-paste').value = %s" % json.dumps(MESSY))
|
|
page.eval("document.getElementById('loc-import-btn').click()")
|
|
settle(1.6)
|
|
report = page.eval("(document.getElementById('loc-report')||{}).textContent||''")
|
|
chk("the report names duplicates as duplicates", "Duplicates, not merged" in report,
|
|
repr(report[:200]))
|
|
chk("...one already in the project, one repeated inside the file",
|
|
"already in this project" in report and "already on line" in report, repr(report[:300]))
|
|
chk("...and rejected rows separately", "Rejected" in report, repr(report[:200]))
|
|
chk("...naming the reason for each", "no building" in report and "no floor above it" in report,
|
|
repr(report[:400]))
|
|
chk("...and the line number, so it can be found in the file",
|
|
re.search(r"row \d+", report) is not None, repr(report[:200]))
|
|
chk("the report interrupts when it lost rows (T4.5)",
|
|
page.eval("document.getElementById('loc-report').getAttribute('role')") == "alert")
|
|
after = rows_in_list(page)
|
|
chk("a duplicate row created nothing", len(after) == 9 + 3,
|
|
[r["code"] for r in after])
|
|
chk("...while the genuinely new rows in the same file did import",
|
|
"PROBE-BUILDING-THREE/PROBE-LEVEL-1/PROBE-SECTOR-A" in [r["code"] for r in after],
|
|
[r["code"] for r in after])
|
|
|
|
print("\n1b. a CSV file goes through the same parser as a paste")
|
|
page.eval("document.getElementById('loc-paste').value = ''")
|
|
page.eval("""(() => {
|
|
const dt = new DataTransfer();
|
|
dt.items.add(new File([%s], 'probe-locations.csv', {type: 'text/csv'}));
|
|
const el = document.getElementById('loc-file');
|
|
el.files = dt.files;
|
|
el.dispatchEvent(new Event('change', {bubbles: true}));
|
|
return true;
|
|
})()""" % json.dumps("Probe Building Four,Probe Level 1,Probe Sector A\n"))
|
|
for _ in range(20):
|
|
if page.eval("!!document.getElementById('loc-paste').value"):
|
|
break
|
|
time.sleep(0.2)
|
|
chk("choosing a file fills the same box a paste does",
|
|
"Probe Building Four" in page.eval("document.getElementById('loc-paste').value"),
|
|
page.eval("document.getElementById('loc-paste').value")[:80])
|
|
chk("...and says which file it read",
|
|
"probe-locations.csv" in page.eval(
|
|
"(document.getElementById('loc-report')||{}).textContent||''"))
|
|
page.eval("document.getElementById('loc-import-btn').click()")
|
|
settle(1.6)
|
|
chk("...and importing it works the same way",
|
|
"PROBE-BUILDING-FOUR/PROBE-LEVEL-1/PROBE-SECTOR-A"
|
|
in [r["code"] for r in rows_in_list(page)],
|
|
[r["code"] for r in rows_in_list(page)])
|
|
|
|
print("\n3. the list is editable after import")
|
|
page.eval("""(() => {
|
|
document.getElementById('loc-add-parent').value = '';
|
|
const n = document.getElementById('loc-add-name');
|
|
n.value = 'Probe Building Five';
|
|
document.getElementById('loc-add-btn').click();
|
|
return true;
|
|
})()""")
|
|
settle(1.4)
|
|
chk("a value can be added by hand",
|
|
"PROBE-BUILDING-FIVE" in [r["code"] for r in rows_in_list(page)],
|
|
[r["code"] for r in rows_in_list(page)])
|
|
page.eval("""(() => {
|
|
const n = document.getElementById('loc-add-name');
|
|
n.value = ' ';
|
|
document.getElementById('loc-add-btn').click();
|
|
return true;
|
|
})()""")
|
|
settle(0.6)
|
|
chk("...an empty one is refused at the field, not in a dialog",
|
|
bool((page.eval("(document.getElementById('loc-add-err')||{}).textContent||''")).strip()),
|
|
page.eval("(document.getElementById('loc-add-err')||{}).textContent||''"))
|
|
chk("...announced through a live region",
|
|
page.eval("document.getElementById('loc-add-err').getAttribute('role')") == "alert")
|
|
dup = api(page, "POST", "/api/projects/projA/locations",
|
|
{"level": "building", "name": "Probe Building Five"})
|
|
chk("...and adding one that already exists is refused with a reason",
|
|
dup["status"] == 409 and "already" in str(dup["body"].get("detail", "")).lower(), dup)
|
|
orphan = api(page, "POST", "/api/projects/projA/locations",
|
|
{"level": "sector", "name": "Probe Loose Sector"})
|
|
chk("...a sector with nothing above it is refused",
|
|
orphan["status"] == 400 and "floor" in str(orphan["body"].get("detail", "")).lower(),
|
|
orphan)
|
|
|
|
print("\n4. deactivating hides a value without breaking what references it")
|
|
nodes = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
|
floor = [n for n in nodes if n["path"] == "PROBE-BUILDING-ONE/PROBE-LEVEL-1"][0]
|
|
off = api(page, "PATCH", "/api/projects/projA/locations/" + floor["id"], {"active": False})
|
|
chk("a value can be deactivated", off["status"] == 200 and off["body"]["active"] is False, off)
|
|
|
|
live = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
|
live_paths = [n["path"] for n in live]
|
|
chk("...and disappears from the list new work packages choose from",
|
|
floor["path"] not in live_paths, live_paths)
|
|
chk("...taking its sectors with it, so nothing offers a path to nowhere",
|
|
not [p for p in live_paths if p.startswith(floor["path"] + "/")], live_paths)
|
|
chk("...while its building stays offered", "PROBE-BUILDING-ONE" in live_paths, live_paths)
|
|
|
|
all_nodes = api(page, "GET",
|
|
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
|
kept = [n for n in all_nodes if n["path"] == floor["path"]]
|
|
chk("...the row itself is RETAINED, not deleted", len(kept) == 1, [n["path"] for n in all_nodes])
|
|
chk("...so a work package already referencing it still resolves its label",
|
|
kept and kept[0]["name"] == floor["name"], kept)
|
|
chk("...and there is no DELETE route at all to lose it by",
|
|
api(page, "DELETE", "/api/projects/projA/locations/" + floor["id"])["status"] == 405,
|
|
api(page, "DELETE", "/api/projects/projA/locations/" + floor["id"]))
|
|
|
|
open_step11(page, base, tok)
|
|
shown = rows_in_list(page)
|
|
chk("the wizard still shows the deactivated value, so it can be brought back",
|
|
any(r["code"] == floor["path"] and r["off"] for r in shown),
|
|
[(r["code"], r["off"]) for r in shown])
|
|
page.eval("""(() => {
|
|
const row = [...document.querySelectorAll('#loc-list .loc-row')]
|
|
.find(r => (r.querySelector('.loc-code')||{}).textContent === %s);
|
|
const box = row.querySelector('.loc-active');
|
|
box.checked = true;
|
|
box.dispatchEvent(new Event('change', {bubbles: true}));
|
|
return true;
|
|
})()""" % json.dumps(floor["path"]))
|
|
settle(1.4)
|
|
back = [n["path"] for n in api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]]
|
|
chk("ticking it puts it back in use", floor["path"] in back, back)
|
|
chk("...on the SAME row, so nothing that referenced it was orphaned",
|
|
[n for n in api(page, "GET",
|
|
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
|
if n["path"] == floor["path"]][0]["id"] == floor["id"])
|
|
|
|
print("\n re-importing a deactivated value brings it back rather than duplicating it")
|
|
all_now = api(page, "GET",
|
|
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
|
live_now = [n["path"] for n in
|
|
api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]]
|
|
sec = [n for n in all_now if n["path"].endswith("/PROBE-SECTOR-B")][0]
|
|
# Deliberate asymmetry, pinned here so nobody "fixes" it into a surprise:
|
|
# deactivating a floor takes its sectors down with it, but ticking the floor
|
|
# back on does NOT resurrect them. A sector may have been switched off for its
|
|
# own reasons, and silently bringing it back would undo a decision nobody made
|
|
# twice. They stay listed, struck through, one tick away.
|
|
chk("reactivating a parent does not silently resurrect its children",
|
|
sec["path"] not in live_now, [sec["path"], live_now])
|
|
chk("...and they are still listed, so they can be brought back deliberately",
|
|
sec["active"] is False)
|
|
api(page, "PATCH", "/api/projects/projA/locations/" + sec["id"], {"active": False})
|
|
again = api(page, "POST", "/api/projects/projA/locations/import",
|
|
{"text": "Probe Building One,Probe Level 1,Probe Sector B"})
|
|
chk("the import reports it as reactivated, not created or duplicate",
|
|
len(again["body"]["reactivated"]) == 1 and not again["body"]["created"], again["body"])
|
|
# The 2026-08-23 production 500, pinned (locations side): Postgres-refused
|
|
# values reject by line, on every dialect, never crash the request.
|
|
hz = api(page, "POST", "/api/projects/projA/locations/import",
|
|
{"text": "Probe Building One," + "Y" * 220 + ",S1", "dry_run": True})
|
|
chk("an over-long name is a line rejection, not a 500",
|
|
hz["status"] == 200 and hz["body"]["rejected"]
|
|
and "200 characters" in hz["body"]["rejected"][0]["reason"], hz["body"])
|
|
same = [n for n in api(page, "GET",
|
|
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
|
if n["path"] == sec["path"]]
|
|
chk("...and reuses the same row", len(same) == 1 and same[0]["id"] == sec["id"], same)
|
|
|
|
print("\n6. no guessed real-world floor names, and no localStorage behind the list")
|
|
chk("no native dialog was opened anywhere in this flow",
|
|
not json.loads(page.eval("JSON.stringify(window.__dialogs||[])")),
|
|
page.eval("JSON.stringify(window.__dialogs||[])"))
|
|
src_js = open(os.path.join(ROOT, "html", "work-package-suite-app.js"), encoding="utf-8").read()
|
|
start = src_js.find("// ── STEP 11: LOCATION LIST")
|
|
end = src_js.find("// ── STEP GATES", start) if start >= 0 else -1
|
|
block = src_js[start:end] if start >= 0 and end > start else ""
|
|
chk("the locations code exists as its own bounded block", bool(block), [start, end])
|
|
# Comments stripped first: this block QUOTES CLAUDE.md's rule about localStorage,
|
|
# and a probe that failed on the sentence forbidding the thing rather than on the
|
|
# thing would be the least useful possible false positive.
|
|
code_only = "\n".join(ln for ln in block.splitlines()
|
|
if not ln.strip().startswith(("//", "*", "/*")))
|
|
chk("...and never touches localStorage", "localStorage" not in code_only,
|
|
[ln for ln in code_only.splitlines() if "localStorage" in ln][:3])
|
|
# Re-pointed at T8.6, not relaxed: the fetches moved into the shared list
|
|
# component (wp-list-import.js) when the material list was built "against
|
|
# the same component" (D6). The proposition is unchanged - every read and
|
|
# write reaches the server - so it is asserted where the fetches now live,
|
|
# plus the wiring that sends THIS list's traffic there.
|
|
comp = open(os.path.join(ROOT, "html", "wp-list-import.js"), encoding="utf-8").read()
|
|
comp_code = chr(10).join(ln for ln in comp.splitlines()
|
|
if not ln.strip().startswith(("//", "*", "/*")))
|
|
chk("...reaching the server for every read and write",
|
|
"api: locApi" in block and comp_code.count("fetch(cfg.api") >= 4
|
|
and "localStorage" not in comp_code,
|
|
(block.count("api: locApi"), comp_code.count("fetch(cfg.api")))
|
|
|
|
# The B100 floor/area list has not been supplied (IMPLEMENTATION.md section 8),
|
|
# so any of these appearing as a location value would be a guess presented as
|
|
# data. Checked over the whole tree, not only the file this task touched.
|
|
GUESSES = ("B100", "1P", "2P", "Fab 7", "Level 2 chase")
|
|
hits = []
|
|
for folder in ("html", "server"):
|
|
for name in sorted(os.listdir(os.path.join(ROOT, folder))):
|
|
if not name.endswith((".js", ".html", ".py", ".css")):
|
|
continue
|
|
text = open(os.path.join(ROOT, folder, name), encoding="utf-8").read()
|
|
# Only where it would be a location VALUE — the review quotes "1P" in
|
|
# prose all over the place, and a comment is not a hardcoded floor.
|
|
for m in re.finditer(r"(building|floor|sector|location)\w*\s*[:=]\s*['\"]([^'\"]{1,40})",
|
|
text, re.I):
|
|
if any(g.lower() in m.group(2).lower() for g in GUESSES):
|
|
hits.append("%s/%s: %s" % (folder, name, m.group(0)[:60]))
|
|
chk("no guessed real-world floor or sector name is assigned anywhere", not hits, hits[:4])
|
|
sample = re.search(r"const LOCATION_SAMPLE = \[(.*?)\]", src_js, re.S)
|
|
chk("the seeded sample values are obviously fake", bool(sample)
|
|
and all("Sample" in ln for ln in re.findall(r"'([^']+)'", sample.group(1))),
|
|
sample.group(1)[:200] if sample else None)
|
|
|
|
print("\n both widths")
|
|
for w, label in ((390, "390px"), (1440, "1440px")):
|
|
page.viewport(w, 900, mobile=(w == 390))
|
|
open_step11(page, base, tok)
|
|
chk("%s: the location step renders" % label,
|
|
page.eval("document.querySelectorAll('#loc-list .loc-row').length") > 0)
|
|
chk("%s: the page does not scroll sideways" % label,
|
|
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
|
|
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
|
|
page.viewport(1400, 1000)
|
|
|
|
|
|
def main():
|
|
exe = cdp.find_browser()
|
|
if not exe:
|
|
print("no headless-capable browser found; set WP_BROWSER.")
|
|
return 2
|
|
|
|
tmpdir = tempfile.mkdtemp(prefix="wpsuite-locations-")
|
|
db_path = os.path.join(tmpdir, "check.db")
|
|
server = None
|
|
try:
|
|
tok = seed(db_path)
|
|
port = cdp.free_port()
|
|
base = "http://127.0.0.1:%d" % port
|
|
server = start_server(port, db_path)
|
|
if server is None:
|
|
print("the test server would not start.")
|
|
return 2
|
|
print("\nLocation taxonomy — CR-005\nTarget: %s" % base)
|
|
|
|
browser = cdp.Browser(exe)
|
|
page = browser.page()
|
|
try:
|
|
run(page, base, tok)
|
|
finally:
|
|
page.close()
|
|
browser.close()
|
|
finally:
|
|
if server:
|
|
server.kill()
|
|
try:
|
|
server.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
try:
|
|
from server.db import engine
|
|
engine.dispose()
|
|
except Exception:
|
|
pass
|
|
import shutil
|
|
for _ in range(10):
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
if not os.path.exists(tmpdir):
|
|
break
|
|
time.sleep(0.3)
|
|
|
|
total = len(_PASS) + len(_FAIL)
|
|
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
|
|
if _FAIL:
|
|
for f in _FAIL:
|
|
print(" - " + f)
|
|
return 1
|
|
print("\nResult: " + _c("ALL PASS — codes not labels, nothing merged, nothing deleted.", "32") + "\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|