- Controllers/SelfTestController.wsc: runs the same checks as
tests/Test-Components.vbs in-process, returns JSON
({ok, checks:[{name,pass,detail}]}) - no PowerShell/cscript/SSH needed
- Application.wsc routes /self-test to it; always HTTP 200 (pass/fail lives
in the JSON body, matching conventional health-check design)
- tests/run-self-test.sh: pure curl + python3 wrapper, verified working
directly from the Linux host with zero Windows tooling
- tests/Test-Http.ps1: now also calls /self-test and folds each check into
its own PASS/FAIL output
- Verified both the happy path and a genuine failure path (deliberately
broke HomeController's registration, confirmed /self-test correctly
reported ok:false with the specific failing check pinpointed, then
re-registered and confirmed full recovery)
- docs updated: ARCHITECTURE.md, DECISIONS.md, TEST-RESULTS.md
master
| @@ -0,0 +1,160 @@ | |||||
| <?xml version="1.0"?> | |||||
| <?component error="true" debug="false"?> | |||||
| <component> | |||||
| <registration | |||||
| description="WscMvc SelfTestController" | |||||
| progid="WscMvc.SelfTestController" | |||||
| version="1.00" | |||||
| classid="{D2634944-4646-4C55-956E-4C05E7E10904}"> | |||||
| </registration> | |||||
| <public> | |||||
| <method name="RunSelfTest"> | |||||
| <parameter name="logDir"/> | |||||
| <parameter name="body"/> | |||||
| </method> | |||||
| </public> | |||||
| <script language="VBScript"> | |||||
| <![CDATA[ | |||||
| Option Explicit | |||||
| ' HTTP+JSON test harness, callable as GET /self-test from any CLI (curl, | |||||
| ' Invoke-WebRequest, etc.) - no cscript/PowerShell/WSH access to the VM | |||||
| ' required. Runs the same checks as tests/Test-Components.vbs, in-process, | |||||
| ' against the live registered components. Intentionally returns raw | |||||
| ' Err.Description in failure details: unlike Default.asp's own error | |||||
| ' handling (which must never leak internals to an arbitrary client), this | |||||
| ' route IS the diagnostics surface, so its whole job is to say what broke. | |||||
| ' This is a dev/test-milestone tool, not a production data endpoint - | |||||
| ' revisit whether it should be gated/removed during M6 hardening. | |||||
| Sub RunSelfTest(logDir, body) | |||||
| Dim checks, allPass | |||||
| checks = "" | |||||
| allPass = True | |||||
| ' --- RequestContext basic contract --- | |||||
| Dim ctx1, ctx1Ok, ctx1Detail | |||||
| ctx1Ok = False | |||||
| ctx1Detail = "" | |||||
| On Error Resume Next | |||||
| Set ctx1 = CreateObject("WscMvc.RequestContext") | |||||
| If Err.Number = 0 Then ctx1.Initialize "/self-test-a", "GET", logDir | |||||
| If Err.Number <> 0 Then | |||||
| ctx1Detail = Err.Description | |||||
| Err.Clear | |||||
| ElseIf ctx1.Path <> "/self-test-a" Then | |||||
| ctx1Detail = "Path roundtrip mismatch: got [" & ctx1.Path & "]" | |||||
| ElseIf ctx1.HttpMethod <> "GET" Then | |||||
| ctx1Detail = "HttpMethod roundtrip mismatch: got [" & ctx1.HttpMethod & "]" | |||||
| ElseIf Len(ctx1.CorrelationId) = 0 Then | |||||
| ctx1Detail = "CorrelationId was empty" | |||||
| ElseIf ctx1.ElapsedMs() < 0 Then | |||||
| ctx1Detail = "ElapsedMs was negative" | |||||
| Else | |||||
| ctx1Ok = True | |||||
| End If | |||||
| On Error Goto 0 | |||||
| checks = AppendCheck(checks, "request_context_contract", ctx1Ok, ctx1Detail) | |||||
| allPass = allPass And ctx1Ok | |||||
| ' --- Two contexts must not collide on correlation id --- | |||||
| Dim ctx2, distinctOk, distinctDetail | |||||
| distinctOk = False | |||||
| distinctDetail = "" | |||||
| On Error Resume Next | |||||
| Set ctx2 = CreateObject("WscMvc.RequestContext") | |||||
| ctx2.Initialize "/self-test-b", "GET", logDir | |||||
| If Err.Number <> 0 Then | |||||
| distinctDetail = Err.Description | |||||
| Err.Clear | |||||
| ElseIf ctx1.CorrelationId = ctx2.CorrelationId Then | |||||
| distinctDetail = "Both contexts produced the same CorrelationId: " & ctx1.CorrelationId | |||||
| Else | |||||
| distinctOk = True | |||||
| End If | |||||
| On Error Goto 0 | |||||
| checks = AppendCheck(checks, "correlation_id_uniqueness", distinctOk, distinctDetail) | |||||
| allPass = allPass And distinctOk | |||||
| ' --- Application.Run happy path --- | |||||
| Dim app, ctxHello, helloStatus, helloType, helloBody, helloOk, helloDetail | |||||
| helloOk = False | |||||
| helloDetail = "" | |||||
| On Error Resume Next | |||||
| Set app = CreateObject("WscMvc.Application") | |||||
| Set ctxHello = CreateObject("WscMvc.RequestContext") | |||||
| ctxHello.Initialize "/hello", "GET", logDir | |||||
| helloStatus = "" : helloType = "" : helloBody = "" | |||||
| app.Run ctxHello, helloStatus, helloType, helloBody | |||||
| If Err.Number <> 0 Then | |||||
| helloDetail = Err.Description | |||||
| Err.Clear | |||||
| ElseIf helloStatus <> "200 OK" Then | |||||
| helloDetail = "Expected 200 OK, got [" & helloStatus & "]" | |||||
| ElseIf helloType <> "text/html; charset=utf-8" Then | |||||
| helloDetail = "Unexpected content type [" & helloType & "]" | |||||
| ElseIf helloBody <> "Hello from WSC-MVC!" Then | |||||
| helloDetail = "Unexpected body [" & helloBody & "]" | |||||
| Else | |||||
| helloOk = True | |||||
| End If | |||||
| On Error Goto 0 | |||||
| checks = AppendCheck(checks, "application_run_hello", helloOk, helloDetail) | |||||
| allPass = allPass And helloOk | |||||
| ' --- Application.Run unknown route (expected 404, not an error) --- | |||||
| Dim ctxUnknown, unkStatus, unkType, unkBody, unkOk, unkDetail | |||||
| unkOk = False | |||||
| unkDetail = "" | |||||
| On Error Resume Next | |||||
| Set ctxUnknown = CreateObject("WscMvc.RequestContext") | |||||
| ctxUnknown.Initialize "/self-test-does-not-exist", "GET", logDir | |||||
| unkStatus = "" : unkType = "" : unkBody = "" | |||||
| app.Run ctxUnknown, unkStatus, unkType, unkBody | |||||
| If Err.Number <> 0 Then | |||||
| unkDetail = Err.Description | |||||
| Err.Clear | |||||
| ElseIf unkStatus <> "404 Not Found" Then | |||||
| unkDetail = "Expected 404 Not Found, got [" & unkStatus & "]" | |||||
| Else | |||||
| unkOk = True | |||||
| End If | |||||
| On Error Goto 0 | |||||
| checks = AppendCheck(checks, "application_run_unknown_route", unkOk, unkDetail) | |||||
| allPass = allPass And unkOk | |||||
| Set ctx1 = Nothing | |||||
| Set ctx2 = Nothing | |||||
| Set ctxHello = Nothing | |||||
| Set ctxUnknown = Nothing | |||||
| Set app = Nothing | |||||
| body = "{""ok"":" & LCase(CStr(allPass)) & ",""checks"":[" & checks & "]}" | |||||
| End Sub | |||||
| Function AppendCheck(checksSoFar, name, pass, detail) | |||||
| Dim entry | |||||
| entry = "{""name"":""" & JsonEscape(name) & """,""pass"":" & LCase(CStr(pass)) & _ | |||||
| ",""detail"":""" & JsonEscape(detail) & """}" | |||||
| If Len(checksSoFar) = 0 Then | |||||
| AppendCheck = entry | |||||
| Else | |||||
| AppendCheck = checksSoFar & "," & entry | |||||
| End If | |||||
| End Function | |||||
| Function JsonEscape(s) | |||||
| Dim result | |||||
| result = s | |||||
| result = Replace(result, "\", "\\") | |||||
| result = Replace(result, """", "\""") | |||||
| result = Replace(result, vbCrLf, "\n") | |||||
| result = Replace(result, vbCr, "\n") | |||||
| result = Replace(result, vbLf, "\n") | |||||
| result = Replace(result, vbTab, "\t") | |||||
| JsonEscape = result | |||||
| End Function | |||||
| ]]> | |||||
| </script> | |||||
| </component> | |||||
| @@ -64,6 +64,46 @@ Sub Run(ctx, statusLine, contentType, body) | |||||
| contentType = "text/html; charset=utf-8" | contentType = "text/html; charset=utf-8" | ||||
| body = helloBody | body = helloBody | ||||
| Set ctrl = Nothing | Set ctrl = Nothing | ||||
| ElseIf path = "/self-test" Then | |||||
| Set ctrl = Nothing | |||||
| On Error Resume Next | |||||
| Set ctrl = CreateObject("WscMvc.SelfTestController") | |||||
| If Err.Number <> 0 Then | |||||
| Err.Clear | |||||
| On Error Goto 0 | |||||
| statusLine = "500 Internal Server Error" | |||||
| contentType = "text/plain; charset=utf-8" | |||||
| body = "Internal Server Error" | |||||
| LogOutcome ctx, statusLine | |||||
| Exit Sub | |||||
| End If | |||||
| On Error Goto 0 | |||||
| Dim selfTestBody | |||||
| selfTestBody = "" | |||||
| On Error Resume Next | |||||
| ctrl.RunSelfTest ctx.LogDir, selfTestBody | |||||
| If Err.Number <> 0 Then | |||||
| Err.Clear | |||||
| On Error Goto 0 | |||||
| statusLine = "500 Internal Server Error" | |||||
| contentType = "text/plain; charset=utf-8" | |||||
| body = "Internal Server Error" | |||||
| Set ctrl = Nothing | |||||
| LogOutcome ctx, statusLine | |||||
| Exit Sub | |||||
| End If | |||||
| On Error Goto 0 | |||||
| ' Always 200: the self-test HTTP call itself succeeded. Whether the | |||||
| ' underlying checks passed is reported in the JSON body's "ok" field | |||||
| ' and per-check "pass" fields, not the HTTP status - this matches | |||||
| ' conventional health-check endpoint design (5xx is reserved for the | |||||
| ' diagnostics mechanism itself being broken, handled above). | |||||
| statusLine = "200 OK" | |||||
| contentType = "application/json; charset=utf-8" | |||||
| body = selfTestBody | |||||
| Set ctrl = Nothing | |||||
| Else | Else | ||||
| ' Expected outcome, not a failure: no matching route yet (M3 adds a real table). | ' Expected outcome, not a failure: no matching route yet (M3 adds a real table). | ||||
| statusLine = "404 Not Found" | statusLine = "404 Not Found" | ||||
| @@ -29,6 +29,19 @@ GET /hello | |||||
| - `RequestContext.Initialize` generates a correlation id from `Fix(Timer)` plus `Scripting.FileSystemObject.GetTempName()` — not `Randomize`/`Rnd()`, which was tried first and shown experimentally to collide when two contexts are created within the same clock tick (see `docs/DECISIONS.md`). | - `RequestContext.Initialize` generates a correlation id from `Fix(Timer)` plus `Scripting.FileSystemObject.GetTempName()` — not `Randomize`/`Rnd()`, which was tried first and shown experimentally to collide when two contexts are created within the same clock tick (see `docs/DECISIONS.md`). | ||||
| - `Application.LogOutcome` appends one line per request (timestamp, correlation id, method, path, status line, elapsed ms) to `logs/app.log`, guarded end-to-end by `On Error Resume Next` so a logging failure can never affect the HTTP response. Concurrent writers are serialized with a manual lock-file mutex (`logs/app.log.lock`, via `CreateTextFile(..., OverwriteExisting:=False)`) since no ASP intrinsic locking primitive (`Application.Lock`) is available to code that must not reference ASP intrinsics. The retry budget is deliberately short (see `docs/DECISIONS.md`): logging is explicitly best-effort and may drop lines under heavy concurrency without affecting correctness of the response. | - `Application.LogOutcome` appends one line per request (timestamp, correlation id, method, path, status line, elapsed ms) to `logs/app.log`, guarded end-to-end by `On Error Resume Next` so a logging failure can never affect the HTTP response. Concurrent writers are serialized with a manual lock-file mutex (`logs/app.log.lock`, via `CreateTextFile(..., OverwriteExisting:=False)`) since no ASP intrinsic locking primitive (`Application.Lock`) is available to code that must not reference ASP intrinsics. The retry budget is deliberately short (see `docs/DECISIONS.md`): logging is explicitly best-effort and may drop lines under heavy concurrency without affecting correctness of the response. | ||||
| ## Test harness: GET /self-test (JSON, frontend-agnostic) | |||||
| `Controllers/SelfTestController.wsc` (`WscMvc.SelfTestController`) exposes the same checks as `tests/Test-Components.vbs`, but callable over plain HTTP and returning JSON — no PowerShell, cscript, or SSH access to the VM required. `Application.Run` routes `path = "/self-test"` to it. The HTTP status is always `200` if the self-test mechanism itself ran (a broken `SelfTestController`/`Application` still degrades to the normal `500` path); the JSON body's top-level `"ok"` field and each check's `"pass"` field carry the actual test results — this is conventional health-check design (5xx is reserved for the diagnostics mechanism being broken, not for a failed assertion inside it). | |||||
| ``` | |||||
| curl http://100.127.62.31:8090/self-test | |||||
| {"ok":true,"checks":[{"name":"request_context_contract","pass":true,"detail":""}, ...]} | |||||
| ``` | |||||
| `tests/run-self-test.sh` wraps this in `curl` + `python3 -m json.tool`, runnable from any CLI (this was verified working directly from the Linux OpenClaw host, not just from the VM). `tests/Test-Http.ps1` also calls `/self-test` and folds each check into its own PASS/FAIL output, complementing (not duplicating) its own direct `/hello` and denied-`.wsc`-access checks, which `/self-test` cannot observe about itself since those are IIS-layer/`web.config` concerns, not component-layer ones. | |||||
| `SelfTestController`'s error details intentionally include raw `Err.Description` text — unlike every other route, which must never leak internals to an arbitrary caller, this route's entire purpose is diagnostics. It is a dev/test-milestone tool; reconsider whether it should be gated or removed before any production deployment during the M6 hardening pass. | |||||
| ## COM identity | ## COM identity | ||||
| | Component | ProgID | CLSID | | | Component | ProgID | CLSID | | ||||
| @@ -36,6 +49,7 @@ GET /hello | |||||
| | `Framework/RequestContext.wsc` | `WscMvc.RequestContext` | `{1C36FA55-34DF-4974-94B9-D657389362B2}` | | | `Framework/RequestContext.wsc` | `WscMvc.RequestContext` | `{1C36FA55-34DF-4974-94B9-D657389362B2}` | | ||||
| | `Framework/Application.wsc` | `WscMvc.Application` | `{851C7763-1638-42FE-A166-BF3DD3A96A88}` | | | `Framework/Application.wsc` | `WscMvc.Application` | `{851C7763-1638-42FE-A166-BF3DD3A96A88}` | | ||||
| | `Controllers/HomeController.wsc` | `WscMvc.HomeController` | `{87488446-60BE-4068-8368-0B709BB68F3F}` | | | `Controllers/HomeController.wsc` | `WscMvc.HomeController` | `{87488446-60BE-4068-8368-0B709BB68F3F}` | | ||||
| | `Controllers/SelfTestController.wsc` | `WscMvc.SelfTestController` | `{D2634944-4646-4C55-956E-4C05E7E10904}` | | |||||
| CLSIDs are fixed at creation and must never be recycled for a different component (AGENTS.md). `Application`'s public `Run` signature changed between M1 and M2 (added a leading `ctx` parameter) under the same CLSID; acceptable because this is active pre-release (v0.1) development with exactly one caller (`Default.asp`, updated in lockstep) — not a claim that live interface changes are safe for a published/external client. | CLSIDs are fixed at creation and must never be recycled for a different component (AGENTS.md). `Application`'s public `Run` signature changed between M1 and M2 (added a leading `ctx` parameter) under the same CLSID; acceptable because this is active pre-release (v0.1) development with exactly one caller (`Default.asp`, updated in lockstep) — not a claim that live interface changes are safe for a published/external client. | ||||
| @@ -63,3 +63,11 @@ Three real defects were found and fixed while building this, all confirmed exper | |||||
| ## Investigated and resolved: an inherited `IUSR:(F)` ACE appeared under `logs/` | ## Investigated and resolved: an inherited `IUSR:(F)` ACE appeared under `logs/` | ||||
| While diagnosing the concurrency finding above, `icacls C:\Projects\wsc-mvc\logs` showed an inherited `NT AUTHORITY\IUSR:(I)(F)` (Full Control) entry that wasn't present as an explicit ACE on `C:\Projects\wsc-mvc` or `C:\Projects` themselves. Initially flagged as a possible SPEC §10 "no broad filesystem write privileges" violation (anonymous web identity with Full Control beyond what it needs). Investigated properly rather than left as a guess: a recursive `icacls C:\Projects\wsc-mvc /T` scan shows the `IUSR:(F)` ACE present **only** on `logs/` and files/folders created underneath it during testing (`app.log`, diagnostic marker files, etc.) — confirmed absent on `Framework/`, `Controllers/`, `Default.asp`, `web.config`, `tools/`, `tests/`. Both `C:\Projects` and `C:\Projects\wsc-mvc` carry an inherited `CREATOR OWNER:(I)(OI)(CI)(IO)(F)` ACE (the `(IO)` = inherit-only flag), which is standard NTFS behavior: it doesn't grant CREATOR OWNER anything on the folder itself, but materializes into a concrete Full Control ACE for whichever security principal actually creates each new child file/folder underneath. Since IUSR (impersonated by classic ASP for anonymous requests, per this site's `anonymousAuthentication` config) was the one creating files under `logs/` during testing, it received Full Control on exactly those files it created there — nowhere else. **Resolved, not a defect**: this is expected NTFS ownership semantics, correctly scoped to only the dynamically-created log tree (which is already denied over HTTP via `hiddenSegments`), not a broad grant across the source tree. No remediation needed; no M6 follow-up required for this specific concern. | While diagnosing the concurrency finding above, `icacls C:\Projects\wsc-mvc\logs` showed an inherited `NT AUTHORITY\IUSR:(I)(F)` (Full Control) entry that wasn't present as an explicit ACE on `C:\Projects\wsc-mvc` or `C:\Projects` themselves. Initially flagged as a possible SPEC §10 "no broad filesystem write privileges" violation (anonymous web identity with Full Control beyond what it needs). Investigated properly rather than left as a guess: a recursive `icacls C:\Projects\wsc-mvc /T` scan shows the `IUSR:(F)` ACE present **only** on `logs/` and files/folders created underneath it during testing (`app.log`, diagnostic marker files, etc.) — confirmed absent on `Framework/`, `Controllers/`, `Default.asp`, `web.config`, `tools/`, `tests/`. Both `C:\Projects` and `C:\Projects\wsc-mvc` carry an inherited `CREATOR OWNER:(I)(OI)(CI)(IO)(F)` ACE (the `(IO)` = inherit-only flag), which is standard NTFS behavior: it doesn't grant CREATOR OWNER anything on the folder itself, but materializes into a concrete Full Control ACE for whichever security principal actually creates each new child file/folder underneath. Since IUSR (impersonated by classic ASP for anonymous requests, per this site's `anonymousAuthentication` config) was the one creating files under `logs/` during testing, it received Full Control on exactly those files it created there — nowhere else. **Resolved, not a defect**: this is expected NTFS ownership semantics, correctly scoped to only the dynamically-created log tree (which is already denied over HTTP via `hiddenSegments`), not a broad grant across the source tree. No remediation needed; no M6 follow-up required for this specific concern. | ||||
| ## Test harness: HTTP+JSON self-test endpoint (2026-09-19) | |||||
| Daniel asked for the test harness to be "frontend agnostic" — runnable via a plain HTTP/API call from any CLI (not tied to PowerShell/cscript, which require either being on the VM or an SSH hop to it). Added `GET /self-test` (`Controllers/SelfTestController.wsc`), which runs the same checks as `tests/Test-Components.vbs` in-process against the live registered components and returns JSON (`{"ok":bool,"checks":[{"name","pass","detail"}, ...]}`). Verified directly from the Linux OpenClaw host with plain `curl` (no SSH, no PowerShell): a passing run, and a genuine failing run (deliberately removed `WscMvc.HomeController`'s registry entries, confirmed `/self-test` correctly reported `"ok":false` with `application_run_hello` pinpointed and a clear detail message, while every other check still correctly reported `true` — then re-registered and confirmed full recovery). | |||||
| Design choice: HTTP status is always `200` when the self-test mechanism itself executes; pass/fail lives in the JSON body (`"ok"` and per-check `"pass"`). A `5xx` is reserved for the diagnostics mechanism itself being broken (e.g. `SelfTestController` fails to instantiate), which still flows through `Application.Run`'s existing central error handling unchanged. This mirrors conventional health-check endpoint design and keeps a clean separation between "the test infrastructure is broken" and "a test found a real problem." | |||||
| `SelfTestController` is the first component whose failure details intentionally include raw `Err.Description` — everywhere else in this codebase that would violate SPEC §10 ("avoid... raw exception text to clients"), but a diagnostics-only endpoint's entire job is reporting what broke. Flagged in `docs/ARCHITECTURE.md` for reconsideration (gate or remove) before any production deployment, during the M6 hardening pass — not a concern for the current dev/test milestones. | |||||
| @@ -149,6 +149,30 @@ Result: **PASS** — `/hello` returns `500` while unregistered and `200` after r | |||||
| - A logging mechanism with guaranteed no-loss-under-load delivery — explicitly deferred to M6 (see `docs/DECISIONS.md`). | - A logging mechanism with guaranteed no-loss-under-load delivery — explicitly deferred to M6 (see `docs/DECISIONS.md`). | ||||
| - Formal 400/405 method-not-allowed handling — still waiting on M3's route table. | - Formal 400/405 method-not-allowed handling — still waiting on M3's route table. | ||||
| ## Test harness: GET /self-test (frontend-agnostic HTTP+JSON) | |||||
| Requested by Daniel: the harness should be callable "via an api call" with a "json response," runnable from the CLI over plain HTTP, not tied to any specific frontend/tooling. | |||||
| Command (from the Linux OpenClaw host, no SSH/PowerShell/cscript involved): | |||||
| ``` | |||||
| curl http://100.127.62.31:8090/self-test | |||||
| ``` | |||||
| Result on a healthy deployment: **PASS** | |||||
| ```json | |||||
| {"ok":true,"checks":[ | |||||
| {"name":"request_context_contract","pass":true,"detail":""}, | |||||
| {"name":"correlation_id_uniqueness","pass":true,"detail":""}, | |||||
| {"name":"application_run_hello","pass":true,"detail":""}, | |||||
| {"name":"application_run_unknown_route","pass":true,"detail":""} | |||||
| ]} | |||||
| ``` | |||||
| Verified the failure-reporting path is real, not just the happy path: deliberately removed `WscMvc.HomeController`'s registry entries, re-ran `curl .../self-test`, got `"ok":false` with `application_run_hello` pinpointed (`"detail":"Expected 200 OK, got [500 Internal Server Error]"`) while the other three checks correctly still reported `true`; confirmed `GET /hello` itself also correctly degraded to `500` at the same time. Re-registered and confirmed both `/self-test` and `/hello` fully recovered. **PASS** | |||||
| Also verified `tests/run-self-test.sh` (`curl` + `python3 -m json.tool`, zero Windows tooling dependency) and the updated `tests/Test-Http.ps1` (now folds each `/self-test` check into its own PASS/FAIL output alongside its existing direct `/hello` and denied-`.wsc` checks) both work end to end. **PASS** | |||||
| `GET /Controllers/SelfTestController.wsc` (the component's own source file) still correctly denied — `404`, same `hiddenSegments` rule as every other component. **PASS** | |||||
| ## M2 gate status: **PASS** | ## M2 gate status: **PASS** | ||||
| The SPEC §13 M2 gate ("repeated/concurrent requests and failure cases behave predictably") was run on real Windows/IIS and holds unconditionally for HTTP response correctness. Four real defects were found and fixed while building this milestone (WSC property-syntax limitation, a locale-formatting bug, a `Randomize` collision, and a naive concurrent-logging approach that made things worse before a working lock-file mutex was verified) — see `docs/DECISIONS.md` for full detail on each. Proceeding to M3 (routing) is unblocked. | The SPEC §13 M2 gate ("repeated/concurrent requests and failure cases behave predictably") was run on real Windows/IIS and holds unconditionally for HTTP response correctness. Four real defects were found and fixed while building this milestone (WSC property-syntax limitation, a locale-formatting bug, a `Randomize` collision, and a naive concurrent-logging approach that made things worse before a working lock-file mutex was verified) — see `docs/DECISIONS.md` for full detail on each. Proceeding to M3 (routing) is unblocked. | ||||
| @@ -25,6 +25,22 @@ try { | |||||
| Report $false "GET /hello request" $_.Exception.Message | Report $false "GET /hello request" $_.Exception.Message | ||||
| } | } | ||||
| # GET /self-test is the frontend-agnostic JSON harness (Controllers/SelfTestController.wsc) - | |||||
| # callable from any CLI via plain HTTP, not just PowerShell/cscript on the VM. This exercises | |||||
| # the same component-level checks as tests/Test-Components.vbs, in-process, over HTTP. | |||||
| try { | |||||
| $selfTestResp = Invoke-WebRequest -Uri "$BaseUrl/self-test" -UseBasicParsing | |||||
| Report ($selfTestResp.StatusCode -eq 200) "GET /self-test status 200" "got $($selfTestResp.StatusCode)" | |||||
| Report ($selfTestResp.Headers['Content-Type'] -eq 'application/json; charset=utf-8') "GET /self-test content-type" "got $($selfTestResp.Headers['Content-Type'])" | |||||
| $selfTestJson = $selfTestResp.Content | ConvertFrom-Json | |||||
| foreach ($check in $selfTestJson.checks) { | |||||
| Report ([bool]$check.pass) "self-test: $($check.name)" $check.detail | |||||
| } | |||||
| Report ([bool]$selfTestJson.ok) "GET /self-test overall ok" "detail in individual checks above" | |||||
| } catch { | |||||
| Report $false "GET /self-test request" $_.Exception.Message | |||||
| } | |||||
| try { | try { | ||||
| $wscResp = Invoke-WebRequest -Uri "$BaseUrl/Framework/Application.wsc" -UseBasicParsing | $wscResp = Invoke-WebRequest -Uri "$BaseUrl/Framework/Application.wsc" -UseBasicParsing | ||||
| Report $false "GET /Framework/Application.wsc denied" "got $($wscResp.StatusCode)" | Report $false "GET /Framework/Application.wsc denied" "got $($wscResp.StatusCode)" | ||||
| @@ -0,0 +1,23 @@ | |||||
| #!/usr/bin/env bash | |||||
| # Frontend-agnostic test runner: plain curl + a JSON parser, no PowerShell, | |||||
| # no cscript, no SSH access to the VM required. Runnable from any CLI that | |||||
| # can reach the site over HTTP. | |||||
| # | |||||
| # Usage: ./run-self-test.sh http://100.127.62.31:8090 | |||||
| set -euo pipefail | |||||
| BASE_URL="${1:?Usage: run-self-test.sh <base-url>, e.g. http://100.127.62.31:8090}" | |||||
| response=$(curl -sf "$BASE_URL/self-test") | |||||
| echo "$response" | python3 -m json.tool | |||||
| ok=$(echo "$response" | python3 -c 'import json, sys; print("true" if json.load(sys.stdin)["ok"] else "false")') | |||||
| if [ "$ok" = "true" ]; then | |||||
| echo "RESULT: ALL PASS" | |||||
| exit 0 | |||||
| else | |||||
| echo "RESULT: FAILURE" | |||||
| exit 1 | |||||
| fi | |||||
| @@ -10,7 +10,8 @@ if (-not $ProjectRoot) { | |||||
| $components = @( | $components = @( | ||||
| (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'), | (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'), | ||||
| (Join-Path $ProjectRoot 'Framework\Application.wsc'), | (Join-Path $ProjectRoot 'Framework\Application.wsc'), | ||||
| (Join-Path $ProjectRoot 'Controllers\HomeController.wsc') | |||||
| (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'), | |||||
| (Join-Path $ProjectRoot 'Controllers\SelfTestController.wsc') | |||||
| ) | ) | ||||
| foreach ($path in $components) { | foreach ($path in $components) { | ||||
| @@ -15,6 +15,7 @@ if (-not $ProjectRoot) { | |||||
| # can never be touched even if it happened to reuse a ProgID string. | # can never be touched even if it happened to reuse a ProgID string. | ||||
| $components = @( | $components = @( | ||||
| @{ Path = (Join-Path $ProjectRoot 'Controllers\SelfTestController.wsc'); ProgId = 'WscMvc.SelfTestController'; ClassId = '{D2634944-4646-4C55-956E-4C05E7E10904}' }, | |||||
| @{ Path = (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'); ProgId = 'WscMvc.HomeController'; ClassId = '{87488446-60BE-4068-8368-0B709BB68F3F}' }, | @{ Path = (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'); ProgId = 'WscMvc.HomeController'; ClassId = '{87488446-60BE-4068-8368-0B709BB68F3F}' }, | ||||
| @{ Path = (Join-Path $ProjectRoot 'Framework\Application.wsc'); ProgId = 'WscMvc.Application'; ClassId = '{851C7763-1638-42FE-A166-BF3DD3A96A88}' }, | @{ Path = (Join-Path $ProjectRoot 'Framework\Application.wsc'); ProgId = 'WscMvc.Application'; ClassId = '{851C7763-1638-42FE-A166-BF3DD3A96A88}' }, | ||||
| @{ Path = (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'); ProgId = 'WscMvc.RequestContext'; ClassId = '{1C36FA55-34DF-4974-94B9-D657389362B2}' } | @{ Path = (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'); ProgId = 'WscMvc.RequestContext'; ClassId = '{1C36FA55-34DF-4974-94B9-D657389362B2}' } | ||||
| @@ -25,6 +25,10 @@ | |||||
| <match url="^hello/?$" /> | <match url="^hello/?$" /> | ||||
| <action type="Rewrite" url="Default.asp?route=/hello" appendQueryString="false" /> | <action type="Rewrite" url="Default.asp?route=/hello" appendQueryString="false" /> | ||||
| </rule> | </rule> | ||||
| <rule name="WscMvc-SelfTest" stopProcessing="true"> | |||||
| <match url="^self-test/?$" /> | |||||
| <action type="Rewrite" url="Default.asp?route=/self-test" appendQueryString="false" /> | |||||
| </rule> | |||||
| </rules> | </rules> | ||||
| </rewrite> | </rewrite> | ||||
| <defaultDocument> | <defaultDocument> | ||||
Powered by TurnKey Linux.