Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b00a4320a7 | ||
| 8677faf54b | |||
| 4c4d52e3de | |||
| 5ee6894916 | |||
| fd451fd452 | |||
| 90c5ca2248 | |||
| f4f9289bdc | |||
| b3b2903c75 | |||
| b1a13b83fd | |||
| 5b0bec8850 | |||
|
|
b14b8188fe | ||
|
|
0673c4c0d8 | ||
|
|
84693a7af5 | ||
|
|
de150e790d |
18 changed files with 3583 additions and 241 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -2,3 +2,6 @@ node_modules/
|
||||||
dist/
|
dist/
|
||||||
*.tgz
|
*.tgz
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -43,12 +43,16 @@ webapp-scaffold-postprocess-openapi && \
|
||||||
orval
|
orval
|
||||||
```
|
```
|
||||||
|
|
||||||
## `createCoreFetch` (v0.2)
|
## `createCoreFetch` (v0.4 — default flipped to `'body'`)
|
||||||
|
|
||||||
Orval's `client: 'fetch'` calls a mutator `coreFetch<T>(url, init)` and
|
Orval's `client: 'fetch'` calls a mutator `coreFetch<T>(url, init)`. The
|
||||||
expects `{data, status, headers}` back. `createCoreFetch(opts)` returns
|
return shape is configurable; **the default is `'body'`** (returns the
|
||||||
such a function, with credentials/CSRF headers / 204 / content-type
|
parsed JSON body directly, matching Orval's
|
||||||
handling already wired, and hooks for project-specific concerns:
|
`includeHttpResponseReturnType: false`). Pass `responseShape: 'wrapped'`
|
||||||
|
to opt back into `{data, status, headers}` when callers need the
|
||||||
|
Response metadata. `createCoreFetch(opts)` returns such a function, with
|
||||||
|
credentials/CSRF headers / 204 / content-type handling already wired,
|
||||||
|
and hooks for project-specific concerns:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { createCoreFetch } from '@uschuster/webapp-scaffold/core-fetch';
|
import { createCoreFetch } from '@uschuster/webapp-scaffold/core-fetch';
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,10 @@ curl -fsS "${AUTH_HEADER[@]}" "$URL" -o "$OUT"
|
||||||
|
|
||||||
# Verify it's actually JSON with an `openapi` key — oatpp sometimes returns
|
# Verify it's actually JSON with an `openapi` key — oatpp sometimes returns
|
||||||
# HTML on 401 which would silently land here otherwise.
|
# HTML on 401 which would silently land here otherwise.
|
||||||
python3 -c "
|
python3 - "$OUT" <<'PY'
|
||||||
import json, sys
|
import json, sys
|
||||||
d = json.load(open('$OUT'))
|
path = sys.argv[1]
|
||||||
assert 'openapi' in d, 'not an OpenAPI spec (missing \"openapi\" key)'
|
d = json.load(open(path))
|
||||||
print(f\" openapi={d['openapi']}, paths={len(d.get('paths', {}))}\")
|
assert 'openapi' in d, 'not an OpenAPI spec (missing "openapi" key)'
|
||||||
"
|
print(f" openapi={d['openapi']}, paths={len(d.get('paths', {}))}")
|
||||||
|
PY
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,29 @@ containing the config file's parent chain → `$PWD`).
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _build_pattern(old_src: str) -> "re.Pattern[str]":
|
||||||
|
# Match `src` on <script> and `href` on <link>, single- or double-quoted.
|
||||||
|
# We anchor to the tag name so an `old_src` substring sitting inside an
|
||||||
|
# HTML comment, a JSON literal, or a `data-…` attribute is not rewritten.
|
||||||
|
return re.compile(
|
||||||
|
r'(<(?:script|link)\b[^>]*?\b(?:src|href)\s*=\s*["\'])'
|
||||||
|
+ re.escape(old_src)
|
||||||
|
+ r'(["\'])',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite(html: str, old_src: str, new_src: str) -> "tuple[str, int]":
|
||||||
|
"""Return (new_html, count). Tag-aware: only rewrites <script src> /
|
||||||
|
<link href> attributes, never substring matches in comments or JSON."""
|
||||||
|
pattern = _build_pattern(old_src)
|
||||||
|
return pattern.subn(lambda m: m.group(1) + new_src + m.group(2), html)
|
||||||
|
|
||||||
|
|
||||||
def inject(manifest_path: str, html_path: str, old_src: str) -> None:
|
def inject(manifest_path: str, html_path: str, old_src: str) -> None:
|
||||||
if not os.path.exists(manifest_path):
|
if not os.path.exists(manifest_path):
|
||||||
print(f"skip: no manifest at {manifest_path}")
|
print(f"skip: no manifest at {manifest_path}")
|
||||||
|
|
@ -45,12 +65,18 @@ def inject(manifest_path: str, html_path: str, old_src: str) -> None:
|
||||||
new_src = f"{os.path.dirname(old_src)}/{hashed}"
|
new_src = f"{os.path.dirname(old_src)}/{hashed}"
|
||||||
with open(html_path) as f:
|
with open(html_path) as f:
|
||||||
html = f.read()
|
html = f.read()
|
||||||
if old_src not in html:
|
new_html, count = rewrite(html, old_src, new_src)
|
||||||
print(f"skip: {old_src!r} not in {html_path}")
|
if count == 0:
|
||||||
|
# Loud warning — silent skip used to mask typos in `old_src`.
|
||||||
|
print(
|
||||||
|
f"WARN: no <script src> or <link href> matching {old_src!r} "
|
||||||
|
f"in {html_path} — leaving file unchanged",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
with open(html_path, "w") as f:
|
with open(html_path, "w") as f:
|
||||||
f.write(html.replace(old_src, new_src))
|
f.write(new_html)
|
||||||
print(f"{old_src} -> {new_src}")
|
print(f"{old_src} -> {new_src} ({count} occurrence{'s' if count != 1 else ''})")
|
||||||
return
|
return
|
||||||
print(f"skip: no isEntry row in {manifest_path}")
|
print(f"skip: no isEntry row in {manifest_path}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,5 +43,60 @@ for path, methods in paths.items():
|
||||||
op.setdefault("operationId", op_id(method, path))
|
op.setdefault("operationId", op_id(method, path))
|
||||||
op.setdefault("tags", [path.strip("/").split("/")[1] if "/" in path.strip("/") else "default"])
|
op.setdefault("tags", [path.strip("/").split("/")[1] if "/" in path.strip("/") else "default"])
|
||||||
|
|
||||||
|
# oatpp 1.3 emits `"required": []` on schemas that have no required fields.
|
||||||
|
# OpenAPI 3.0.x rejects this (`must NOT have fewer than 1 items`), which
|
||||||
|
# breaks strict consumers like Orval's Zod generator. Strip empty arrays —
|
||||||
|
# absence of the keyword has the same semantics.
|
||||||
|
schemas = spec.get("components", {}).get("schemas", {})
|
||||||
|
stripped_required = 0
|
||||||
|
for sch in schemas.values():
|
||||||
|
if isinstance(sch, dict) and sch.get("required") == []:
|
||||||
|
del sch["required"]
|
||||||
|
stripped_required += 1
|
||||||
|
|
||||||
|
# oatpp 1.3 emits `"type": "Any"` for `oatpp::Any` / `oatpp::Fields` fields.
|
||||||
|
# `Any` is not a valid OpenAPI 3.0 type — the spec uses an empty schema `{}`
|
||||||
|
# (or `nullable: true` with no type) to mean "any value". Replace `Any` with
|
||||||
|
# an empty schema; recurse so we catch nested cases (object property,
|
||||||
|
# array item, etc.).
|
||||||
|
fixed_any = 0
|
||||||
|
def fix_any(node):
|
||||||
|
global fixed_any
|
||||||
|
if isinstance(node, dict):
|
||||||
|
if node.get("type") == "Any":
|
||||||
|
del node["type"]
|
||||||
|
fixed_any += 1
|
||||||
|
for v in node.values():
|
||||||
|
fix_any(v)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
fix_any(item)
|
||||||
|
fix_any(spec)
|
||||||
|
|
||||||
|
# oatpp 1.3 sometimes emits the same path parameter twice (same name + same
|
||||||
|
# `in: path`). OpenAPI 3.0 rejects duplicate parameters. Dedupe by (name, in)
|
||||||
|
# preserving first occurrence.
|
||||||
|
deduped_params = 0
|
||||||
|
for path_item in paths.values():
|
||||||
|
if not isinstance(path_item, dict):
|
||||||
|
continue
|
||||||
|
for op in path_item.values():
|
||||||
|
if not isinstance(op, dict) or "parameters" not in op:
|
||||||
|
continue
|
||||||
|
seen = set()
|
||||||
|
unique = []
|
||||||
|
for p in op["parameters"]:
|
||||||
|
key = (p.get("name"), p.get("in"))
|
||||||
|
if key in seen:
|
||||||
|
deduped_params += 1
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
unique.append(p)
|
||||||
|
op["parameters"] = unique
|
||||||
|
|
||||||
SRC.write_text(json.dumps(spec, indent=2))
|
SRC.write_text(json.dumps(spec, indent=2))
|
||||||
print(f" postprocessed {SRC} — {len(paths)} paths")
|
report = [f"{len(paths)} paths"]
|
||||||
|
if stripped_required: report.append(f"stripped empty `required` from {stripped_required} schemas")
|
||||||
|
if fixed_any: report.append(f"replaced {fixed_any} invalid `type: Any` with any-schema")
|
||||||
|
if deduped_params: report.append(f"deduped {deduped_params} duplicate parameter(s)")
|
||||||
|
print(f" postprocessed {SRC} — " + ", ".join(report))
|
||||||
|
|
|
||||||
2677
package-lock.json
generated
2677
package-lock.json
generated
File diff suppressed because it is too large
Load diff
21
package.json
21
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@uschuster/webapp-scaffold",
|
"name": "@uschuster/webapp-scaffold",
|
||||||
"version": "0.3.1",
|
"version": "0.5.0",
|
||||||
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
|
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -32,12 +32,23 @@
|
||||||
},
|
},
|
||||||
"files": ["bin/", "dist/", "src/", "templates/", "README.md", "LICENSE"],
|
"files": ["bin/", "dist/", "src/", "templates/", "README.md", "LICENSE"],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "tsc || true"
|
"prepare": "tsc && vitest run",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0",
|
||||||
|
"vitest": "^2.0.0",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@testing-library/dom": "^10.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
|
|
|
||||||
195
src/core-fetch.test.ts
Normal file
195
src/core-fetch.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
// Vitest suite for createCoreFetch (#4).
|
||||||
|
//
|
||||||
|
// Covers the user-visible response shapes, hook wiring (onEnqueue, on401,
|
||||||
|
// on409, onNetworkFailure, formatError), and the boundary cases the audit
|
||||||
|
// flagged: 401 hook fires + caller still gets an error, 409 hook can swallow,
|
||||||
|
// network failure hook can synthesise a body, non-JSON 2xx returns text.
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createCoreFetch } from './core-fetch.js';
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
...init,
|
||||||
|
headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createCoreFetch — response shapes', () => {
|
||||||
|
it("'body' default returns the parsed body directly", async () => {
|
||||||
|
const cf = createCoreFetch({ fetchImpl: () => Promise.resolve(jsonResponse({ ok: 1 })) });
|
||||||
|
const r = await cf<{ ok: number }>('/x');
|
||||||
|
expect(r).toEqual({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'wrapped' opt-in returns { data, status, headers }", async () => {
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
responseShape: 'wrapped',
|
||||||
|
fetchImpl: () => Promise.resolve(jsonResponse({ ok: 1 })),
|
||||||
|
});
|
||||||
|
const r = await cf<{ data: { ok: number }; status: number }>('/x');
|
||||||
|
expect(r.data).toEqual({ ok: 1 });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-JSON 2xx returns the body as plain text', async () => {
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
responseShape: 'body',
|
||||||
|
fetchImpl: () => Promise.resolve(new Response('hello', {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/plain' },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const r = await cf<string>('/x');
|
||||||
|
expect(r).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('204 No Content returns undefined data with no JSON parse', async () => {
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
responseShape: 'body',
|
||||||
|
fetchImpl: () => Promise.resolve(new Response(null, { status: 204 })),
|
||||||
|
});
|
||||||
|
const r = await cf<undefined>('/x');
|
||||||
|
expect(r).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createCoreFetch — error hooks', () => {
|
||||||
|
it('401 fires on401 then throws', async () => {
|
||||||
|
const on401 = vi.fn();
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
on401,
|
||||||
|
fetchImpl: () => Promise.resolve(new Response('', { status: 401 })),
|
||||||
|
});
|
||||||
|
await expect(cf('/x')).rejects.toThrow();
|
||||||
|
expect(on401).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409 fires on409; resolved value is returned in place of throw', async () => {
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
responseShape: 'body',
|
||||||
|
on409: () => ({ swallowed: true }),
|
||||||
|
fetchImpl: () => Promise.resolve(jsonResponse({ message: 'conflict' }, { status: 409 })),
|
||||||
|
});
|
||||||
|
const r = await cf<{ swallowed: boolean }>('/x');
|
||||||
|
expect(r).toEqual({ swallowed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("409 without on409 throws an error decorated with status=409 + body", async () => {
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
fetchImpl: () => Promise.resolve(jsonResponse({ message: 'nope' }, { status: 409 })),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await cf('/x');
|
||||||
|
expect.unreachable('expected throw');
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error & { status?: number; body?: unknown };
|
||||||
|
expect(err.status).toBe(409);
|
||||||
|
expect(err.body).toEqual({ message: 'nope' });
|
||||||
|
expect(err.message).toBe('nope');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formatError overrides the default error message', async () => {
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
formatError: () => 'custom',
|
||||||
|
fetchImpl: () => Promise.resolve(new Response('boom', { status: 500 })),
|
||||||
|
});
|
||||||
|
await expect(cf('/x')).rejects.toThrow('custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('network failure rethrows by default', async () => {
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
fetchImpl: () => Promise.reject(new Error('offline')),
|
||||||
|
});
|
||||||
|
await expect(cf('/x')).rejects.toThrow('offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onNetworkFailure can synthesise a replacement body', async () => {
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
responseShape: 'body',
|
||||||
|
onNetworkFailure: () => ({ offline: true }),
|
||||||
|
fetchImpl: () => Promise.reject(new Error('offline')),
|
||||||
|
});
|
||||||
|
const r = await cf<{ offline: boolean }>('/x');
|
||||||
|
expect(r).toEqual({ offline: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createCoreFetch — sync queue', () => {
|
||||||
|
it('onEnqueue can short-circuit a mutating request on a sync table', async () => {
|
||||||
|
const fetchImpl = vi.fn();
|
||||||
|
const onEnqueue = vi.fn().mockReturnValue(true);
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
responseShape: 'body',
|
||||||
|
syncTables: new Set(['bookings']),
|
||||||
|
onEnqueue,
|
||||||
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
await cf('/api/bookings', { method: 'POST', body: '{}' });
|
||||||
|
expect(onEnqueue).toHaveBeenCalledOnce();
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-mutating request still hits the network even on sync table', async () => {
|
||||||
|
const fetchImpl = vi.fn(() => Promise.resolve(jsonResponse({})));
|
||||||
|
const onEnqueue = vi.fn();
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
syncTables: new Set(['bookings']),
|
||||||
|
onEnqueue,
|
||||||
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
await cf('/api/bookings');
|
||||||
|
expect(onEnqueue).not.toHaveBeenCalled();
|
||||||
|
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createCoreFetch — request shape', () => {
|
||||||
|
it('always sends X-Requested-With and credentials: include', async () => {
|
||||||
|
let captured: { url: string; init: RequestInit } | null = null;
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
fetchImpl: ((url: string, init: RequestInit) => {
|
||||||
|
captured = { url, init };
|
||||||
|
return Promise.resolve(jsonResponse({}));
|
||||||
|
}) as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
await cf('/x');
|
||||||
|
expect(captured!.init.credentials).toBe('include');
|
||||||
|
const h = captured!.init.headers as Headers;
|
||||||
|
expect(h.get('X-Requested-With')).toBe('XMLHttpRequest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('baseUrl is prepended and trailing slash stripped', async () => {
|
||||||
|
let captured = '';
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
baseUrl: 'https://api.example.com/',
|
||||||
|
fetchImpl: ((url: string) => {
|
||||||
|
captured = url;
|
||||||
|
return Promise.resolve(jsonResponse({}));
|
||||||
|
}) as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
await cf('/v1/x');
|
||||||
|
expect(captured).toBe('https://api.example.com/v1/x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('baseUrl getter is resolved at request time, not factory time', async () => {
|
||||||
|
const captured: string[] = [];
|
||||||
|
let dynamicBase = 'https://first.example.com';
|
||||||
|
const cf = createCoreFetch({
|
||||||
|
baseUrl: () => dynamicBase,
|
||||||
|
fetchImpl: ((url: string) => {
|
||||||
|
captured.push(url);
|
||||||
|
return Promise.resolve(jsonResponse({}));
|
||||||
|
}) as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
await cf('/v1/x');
|
||||||
|
dynamicBase = 'https://second.example.com/';
|
||||||
|
await cf('/v1/y');
|
||||||
|
expect(captured).toEqual([
|
||||||
|
'https://first.example.com/v1/x',
|
||||||
|
'https://second.example.com/v1/y',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,62 +2,124 @@
|
||||||
//
|
//
|
||||||
// Orval's `client: 'fetch'` emits functions that call
|
// Orval's `client: 'fetch'` emits functions that call
|
||||||
// coreFetch<T>(url, init)
|
// coreFetch<T>(url, init)
|
||||||
// and expect `{ data, status, headers }` back. This factory returns such a
|
// and expect one of:
|
||||||
// function, with the baseline behaviour shared across projects:
|
// • the parsed body directly (responseShape: 'body', DEFAULT — matches
|
||||||
|
// orval's `includeHttpResponseReturnType: false`, the Orval-driven
|
||||||
|
// "no extra ceremony" path)
|
||||||
|
// • { data, status, headers } back (responseShape: 'wrapped' — matches
|
||||||
|
// orval's `includeHttpResponseReturnType: true`; opt in when callers
|
||||||
|
// need the Response metadata)
|
||||||
//
|
//
|
||||||
// • credentials: 'include' — cookies always sent
|
// Baseline wired in:
|
||||||
// • X-Requested-With: 'XMLHttpRequest' — matches the oatpp CSRF guard
|
// • credentials: 'include' — cookies always sent
|
||||||
// • 204 No Content → data: undefined — no JSON parse attempted
|
// • X-Requested-With: 'XMLHttpRequest' — matches the oatpp CSRF guard
|
||||||
|
// • 204 No Content → data: undefined — no JSON parse attempted
|
||||||
// • JSON content-type → JSON.parse, else text
|
// • JSON content-type → JSON.parse, else text
|
||||||
//
|
//
|
||||||
// Hooks let projects plug in the app-specific concerns (sync queue, 401
|
// Hooks let projects plug in app-specific concerns (sync queue, 401 redirect,
|
||||||
// redirect, 409 conflict resolution) without forking the mutator.
|
// 409 conflict, network-failure offline fallback, localized error messages)
|
||||||
|
// without forking the mutator.
|
||||||
|
|
||||||
|
export type ResponseShape = 'wrapped' | 'body';
|
||||||
|
|
||||||
|
export interface CoreFetchRequest {
|
||||||
|
url: string;
|
||||||
|
init: RequestInit;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CoreFetchOptions {
|
export interface CoreFetchOptions {
|
||||||
/**
|
/**
|
||||||
* Base path prefix for every request. Typically `BASE_URL` from the
|
* Prefix for every request; trailing slash stripped at resolve time.
|
||||||
* bundler (strip the trailing slash): projects behind an Apache
|
*
|
||||||
* `/projects/<name>/` prefix set this to that path; root-hosted apps
|
* Accepts a string OR a getter so consumers that derive the base from
|
||||||
* leave it empty.
|
* `import.meta.env.BASE_URL` (or any other late-bound source) can pass
|
||||||
|
* the getter form. The getter is invoked at request time, never at
|
||||||
|
* factory-construction time — which keeps orval's CJS bundling of the
|
||||||
|
* mutator file from blowing up on top-level `import.meta` references.
|
||||||
*/
|
*/
|
||||||
baseUrl?: string;
|
baseUrl?: string | (() => string);
|
||||||
|
|
||||||
|
/** `'body'` returns the parsed body (default); `'wrapped'` returns { data, status, headers }. */
|
||||||
|
responseShape?: ResponseShape;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tables whose mutations should route through an offline sync queue
|
* Tables whose mutations route through an offline sync queue ON NETWORK
|
||||||
* instead of executing directly. The mutator calls `onEnqueue` when
|
* FAILURE only — i.e. "online-first, enqueue-on-fail". Pre-emptive
|
||||||
* the request's method is mutating AND the URL matches one of these
|
* always-queue behaviour: set `onEnqueue` to return true directly.
|
||||||
* tables; if that returns `true` the request is considered satisfied
|
|
||||||
* and the factory fabricates a `{status: 202, data: null}` response.
|
|
||||||
*/
|
*/
|
||||||
syncTables?: ReadonlySet<string>;
|
syncTables?: ReadonlySet<string>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueue hook — called with the outgoing request. Return `true` to
|
* Pre-emptive enqueue hook — called BEFORE the network attempt, for
|
||||||
* skip the network call (the request was queued). Return `false` to
|
* mutating requests on matching sync tables. Return `true` to skip the
|
||||||
* proceed with the real fetch.
|
* network call (the request was queued); `false` to proceed.
|
||||||
*/
|
*/
|
||||||
onEnqueue?: (req: { url: string; init: RequestInit }) => boolean;
|
onEnqueue?: (req: CoreFetchRequest) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network-failure hook — called when fetch() itself rejects (e.g. offline).
|
||||||
|
* Return a value to swallow the rejection and use that value as the
|
||||||
|
* response body (returned directly in `'body'` shape, or wrapped in
|
||||||
|
* `{ data, status: 202, headers: {} }` in `'wrapped'` shape). Return
|
||||||
|
* `undefined` (the default) to rethrow. Useful for fewo's
|
||||||
|
* "enqueue mutation on offline, return synthetic ok" pattern.
|
||||||
|
*/
|
||||||
|
onNetworkFailure?: (req: CoreFetchRequest, error: unknown) => unknown;
|
||||||
|
|
||||||
/** Called after any 401 response, before the error is thrown. */
|
/** Called after any 401 response, before the error is thrown. */
|
||||||
on401?: (req: { url: string; init: RequestInit }) => void;
|
on401?: (req: CoreFetchRequest) => void;
|
||||||
|
|
||||||
/** Called after any 409 response; return a resolved value to swallow the error. */
|
/**
|
||||||
on409?: (req: { url: string; init: RequestInit }, body: string) => unknown;
|
* Called after any 409 response; return a resolved value to swallow
|
||||||
|
* the error (returned as the response body), or undefined to throw.
|
||||||
|
*/
|
||||||
|
on409?: (req: CoreFetchRequest, body: string) => unknown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error message formatter. Called for any non-OK response
|
||||||
|
* (after on401 / on409 have run and didn't swallow). Return a string
|
||||||
|
* to use as the Error message; return undefined to fall back to the
|
||||||
|
* default `"<status> <statusText>: <body>"` format. Also receives the
|
||||||
|
* parsed body when it's valid JSON (otherwise `null`).
|
||||||
|
*/
|
||||||
|
formatError?: (
|
||||||
|
req: CoreFetchRequest,
|
||||||
|
response: Response,
|
||||||
|
bodyText: string,
|
||||||
|
bodyJson: unknown | null,
|
||||||
|
) => string | undefined;
|
||||||
|
|
||||||
/** Swap out the fetch impl (tests). */
|
/** Swap out the fetch impl (tests). */
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CoreFetch = <T extends { data: unknown; status: number }>(
|
export type CoreFetchWrapped = <T extends { data: unknown; status: number }>(
|
||||||
url: string,
|
url: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
) => Promise<T>;
|
) => Promise<T>;
|
||||||
|
|
||||||
|
export type CoreFetchBody = <T>(url: string, init?: RequestInit) => Promise<T>;
|
||||||
|
|
||||||
|
export type CoreFetch = CoreFetchWrapped & CoreFetchBody;
|
||||||
|
|
||||||
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
|
|
||||||
export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
|
export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
|
||||||
const base = (opts.baseUrl ?? '').replace(/\/$/, '');
|
// Lazy base resolution — see CoreFetchOptions.baseUrl JSDoc.
|
||||||
const fetchImpl = opts.fetchImpl ?? fetch;
|
const resolveBase = (): string => {
|
||||||
|
const raw = typeof opts.baseUrl === 'function' ? opts.baseUrl() : (opts.baseUrl ?? '');
|
||||||
|
return raw.replace(/\/$/, '');
|
||||||
|
};
|
||||||
|
// Resolve fetch at call time, not construction time, so tests that
|
||||||
|
// `vi.stubGlobal('fetch', ...)` after module import see the stub.
|
||||||
|
const callFetch: typeof fetch = opts.fetchImpl
|
||||||
|
? opts.fetchImpl
|
||||||
|
: ((...args) => fetch(...(args as Parameters<typeof fetch>))) as typeof fetch;
|
||||||
|
const shape: ResponseShape = opts.responseShape ?? 'body';
|
||||||
|
|
||||||
|
function wrap(data: unknown, status: number, headers: Headers): unknown {
|
||||||
|
return shape === 'body' ? data : { data, status, headers };
|
||||||
|
}
|
||||||
|
|
||||||
function matchesSyncTable(url: string): boolean {
|
function matchesSyncTable(url: string): boolean {
|
||||||
if (!opts.syncTables || opts.syncTables.size === 0) return false;
|
if (!opts.syncTables || opts.syncTables.size === 0) return false;
|
||||||
|
|
@ -67,40 +129,60 @@ export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return async function coreFetch<T extends { data: unknown; status: number }>(
|
return async function coreFetch<T>(
|
||||||
url: string,
|
url: string,
|
||||||
init: RequestInit = {},
|
init: RequestInit = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const method = (init.method ?? 'GET').toUpperCase();
|
const method = (init.method ?? 'GET').toUpperCase();
|
||||||
const headers = new Headers(init.headers);
|
const headers = new Headers(init.headers);
|
||||||
headers.set('X-Requested-With', 'XMLHttpRequest');
|
headers.set('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
const req: CoreFetchRequest = { url, init: { ...init, headers } };
|
||||||
|
|
||||||
// Offline sync path.
|
// Pre-emptive enqueue (always-queue pattern).
|
||||||
if (MUTATING.has(method) && matchesSyncTable(url) && opts.onEnqueue) {
|
if (MUTATING.has(method) && matchesSyncTable(url) && opts.onEnqueue) {
|
||||||
if (opts.onEnqueue({ url, init: { ...init, headers } })) {
|
if (opts.onEnqueue(req)) {
|
||||||
return { data: null, status: 202, headers: new Headers() } as unknown as T;
|
return wrap(null, 202, new Headers()) as T;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = await fetchImpl(`${base}${url}`, {
|
let r: Response;
|
||||||
credentials: 'include',
|
try {
|
||||||
...init,
|
r = await callFetch(`${resolveBase()}${url}`, {
|
||||||
headers,
|
credentials: 'include',
|
||||||
});
|
...init,
|
||||||
|
headers,
|
||||||
if (r.status === 401) opts.on401?.({ url, init });
|
});
|
||||||
if (r.status === 409 && opts.on409) {
|
} catch (networkErr) {
|
||||||
const body = await r.text();
|
if (opts.onNetworkFailure) {
|
||||||
const resolved = opts.on409({ url, init }, body);
|
const replacement = opts.onNetworkFailure(req, networkErr);
|
||||||
if (resolved !== undefined) {
|
if (replacement !== undefined) {
|
||||||
return { data: resolved, status: r.status, headers: r.headers } as unknown as T;
|
return wrap(replacement, 0, new Headers()) as T;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw new Error(`${r.status} ${r.statusText}: ${body}`);
|
throw networkErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.status === 401) opts.on401?.(req);
|
||||||
|
if (r.status === 409) {
|
||||||
|
const [text, json] = await readBody(r);
|
||||||
|
if (opts.on409) {
|
||||||
|
const resolved = opts.on409(req, text);
|
||||||
|
if (resolved !== undefined) {
|
||||||
|
return wrap(resolved, r.status, r.headers) as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const msg = opts.formatError?.(req, r, text, json)
|
||||||
|
?? (json && typeof (json as Record<string, unknown>).message === 'string'
|
||||||
|
? String((json as Record<string, unknown>).message)
|
||||||
|
: `${r.status} ${r.statusText}: ${text}`);
|
||||||
|
throw decorate409(new Error(msg), json);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const text = await r.text();
|
const [text, json] = await readBody(r);
|
||||||
throw new Error(`${r.status} ${r.statusText}: ${text}`);
|
const msg = opts.formatError?.(req, r, text, json)
|
||||||
|
?? `${r.status} ${r.statusText}: ${text}`;
|
||||||
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: unknown = undefined;
|
let data: unknown = undefined;
|
||||||
|
|
@ -108,6 +190,39 @@ export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
|
||||||
const ct = r.headers.get('content-type') ?? '';
|
const ct = r.headers.get('content-type') ?? '';
|
||||||
data = ct.includes('application/json') ? await r.json() : await r.text();
|
data = ct.includes('application/json') ? await r.json() : await r.text();
|
||||||
}
|
}
|
||||||
return { data, status: r.status, headers: r.headers } as unknown as T;
|
return wrap(data, r.status, r.headers) as T;
|
||||||
};
|
} as CoreFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(text: string): unknown | null {
|
||||||
|
try { return JSON.parse(text); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read an error-response body. Prefer `.json()` (tests commonly mock only
|
||||||
|
* that); fall back to `.text()` if json() throws. Returns both the raw
|
||||||
|
* text (for `formatError` callers that want to log it) and the parsed
|
||||||
|
* JSON (may be null when the body isn't parseable).
|
||||||
|
*/
|
||||||
|
async function readBody(r: Response): Promise<[string, unknown | null]> {
|
||||||
|
// Some mocks expose .json() but not .clone() (e.g. vitest object mocks).
|
||||||
|
// Try .json() directly; if it throws, fall back to .text().
|
||||||
|
try {
|
||||||
|
const j = await r.json();
|
||||||
|
return [JSON.stringify(j), j];
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const text = await r.text();
|
||||||
|
return [text, safeJson(text)];
|
||||||
|
} catch {
|
||||||
|
return ['', null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach status/body to 409 errors so conflict-resolvers can reach them. */
|
||||||
|
function decorate409(err: Error, body: unknown): Error {
|
||||||
|
(err as Error & { status?: number; body?: unknown }).status = 409;
|
||||||
|
(err as Error & { status?: number; body?: unknown }).body = body;
|
||||||
|
return err;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
src/i18n-react.test.tsx
Normal file
31
src/i18n-react.test.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Vitest suite for useI18nStore (#4) — confirms the React binding re-renders
|
||||||
|
// on locale change. Kept minimal: the React peer is optional, so this is the
|
||||||
|
// only file that pulls in @testing-library/react.
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { act, render, screen } from '@testing-library/react';
|
||||||
|
import { createI18n } from './i18n.js';
|
||||||
|
import { useI18nStore } from './i18n-react.js';
|
||||||
|
|
||||||
|
interface B extends Record<string, string> {
|
||||||
|
hello: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundles = {
|
||||||
|
'de': { hello: 'Hallo' } as Partial<B>,
|
||||||
|
'en': { hello: 'Hello' } as Partial<B>,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useI18nStore', () => {
|
||||||
|
it('re-renders when locale changes', () => {
|
||||||
|
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||||
|
const Comp = () => {
|
||||||
|
const store = useI18nStore(i);
|
||||||
|
return <span data-testid="t">{store.t('hello')}</span>;
|
||||||
|
};
|
||||||
|
render(<Comp />);
|
||||||
|
expect(screen.getByTestId('t').textContent).toBe('Hallo');
|
||||||
|
act(() => { i.setLocale('en'); });
|
||||||
|
expect(screen.getByTestId('t').textContent).toBe('Hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
96
src/i18n.test.ts
Normal file
96
src/i18n.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// Vitest suite for createI18n (#4).
|
||||||
|
//
|
||||||
|
// Pins the resolver chain (`<locale>-<tone>` → `<locale>` → default), the
|
||||||
|
// once-per-key onMiss firing, and the subscribe/notify behaviour.
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createI18n } from './i18n.js';
|
||||||
|
|
||||||
|
interface B extends Record<string, string> {
|
||||||
|
hello: string;
|
||||||
|
bye: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundles = {
|
||||||
|
'de': { hello: 'Hallo', bye: 'Tschüss' } as Partial<B>,
|
||||||
|
'de-informal': { hello: 'Hi' } as Partial<B>,
|
||||||
|
'en': { hello: 'Hello' } as Partial<B>,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('createI18n — resolver chain', () => {
|
||||||
|
it('hits <locale>-<tone> first', () => {
|
||||||
|
const i = createI18n<B>({ bundles, defaultLocale: 'de', initialLocale: 'de', initialTone: 'informal' });
|
||||||
|
expect(i.t('hello')).toBe('Hi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through tone to <locale> when key missing in tone bundle', () => {
|
||||||
|
// 'bye' is only in 'de'; informal asks for 'de-informal' first, falls back to 'de'.
|
||||||
|
const i = createI18n<B>({ bundles, defaultLocale: 'de', initialLocale: 'de', initialTone: 'informal' });
|
||||||
|
expect(i.t('bye')).toBe('Tschüss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through locale to default when current locale has no entry', () => {
|
||||||
|
// 'bye' missing in en → falls back to default 'de'.
|
||||||
|
const i = createI18n<B>({ bundles, defaultLocale: 'de', initialLocale: 'en' });
|
||||||
|
expect(i.t('bye')).toBe('Tschüss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the raw key when nothing resolves', () => {
|
||||||
|
const i = createI18n<B>({ bundles: {}, defaultLocale: 'de' });
|
||||||
|
expect(i.t('hello')).toBe('hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createI18n — onMiss', () => {
|
||||||
|
it('fires once per (key, locale, tone) triple', () => {
|
||||||
|
const onMiss = vi.fn();
|
||||||
|
const i = createI18n<B>({ bundles: {}, defaultLocale: 'de', onMiss });
|
||||||
|
i.t('hello');
|
||||||
|
i.t('hello');
|
||||||
|
i.t('hello');
|
||||||
|
expect(onMiss).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires again after locale flip', () => {
|
||||||
|
const onMiss = vi.fn();
|
||||||
|
const i = createI18n<B>({ bundles: {}, defaultLocale: 'de', onMiss });
|
||||||
|
i.t('hello');
|
||||||
|
i.setLocale('en');
|
||||||
|
i.t('hello');
|
||||||
|
expect(onMiss).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createI18n — subscribe/notify', () => {
|
||||||
|
it('setLocale notifies subscribers exactly once', () => {
|
||||||
|
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||||
|
const fn = vi.fn();
|
||||||
|
i.subscribe(fn);
|
||||||
|
i.setLocale('en');
|
||||||
|
expect(fn).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setLocale to same value does not notify', () => {
|
||||||
|
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||||
|
const fn = vi.fn();
|
||||||
|
i.subscribe(fn);
|
||||||
|
i.setLocale('de');
|
||||||
|
expect(fn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsubscribe stops further notifications', () => {
|
||||||
|
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||||
|
const fn = vi.fn();
|
||||||
|
const unsub = i.subscribe(fn);
|
||||||
|
unsub();
|
||||||
|
i.setLocale('en');
|
||||||
|
expect(fn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSnapshot reflects the current <locale>-<tone>', () => {
|
||||||
|
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||||
|
expect(i.getSnapshot()).toBe('de-formal');
|
||||||
|
i.setTone('informal');
|
||||||
|
expect(i.getSnapshot()).toBe('de-informal');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,8 +6,15 @@
|
||||||
// Both honour the same Apache-proxy-prefix baseUrl convention and keep
|
// Both honour the same Apache-proxy-prefix baseUrl convention and keep
|
||||||
// their outputs under a conventional path so the `StaticController` and
|
// their outputs under a conventional path so the `StaticController` and
|
||||||
// Apache vhost can serve them without per-project tweaks.
|
// Apache vhost can serve them without per-project tweaks.
|
||||||
|
//
|
||||||
|
// VITE_BASE resolution: Vite does NOT populate `process.env.VITE_BASE` from
|
||||||
|
// .env files at config-evaluation time (those go into `import.meta.env` for
|
||||||
|
// the client bundle only). To pick up the value `new-project.sh` writes to
|
||||||
|
// `frontend/.env.production`, we use Vite's own `loadEnv()` helper. A
|
||||||
|
// `process.env.VITE_BASE` override still wins so CI invocations that
|
||||||
|
// `export VITE_BASE=…` keep working.
|
||||||
|
|
||||||
import type { UserConfig } from 'vite';
|
import { defineConfig, loadEnv, type UserConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
|
@ -20,10 +27,15 @@ export interface AdminConfigOptions {
|
||||||
outDir?: string;
|
outDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defineAdminConfig(opts: AdminConfigOptions): UserConfig {
|
function resolveBase(mode: string, root: string): string {
|
||||||
const base = process.env.VITE_BASE || '/';
|
if (process.env.VITE_BASE) return process.env.VITE_BASE;
|
||||||
return {
|
const env = loadEnv(mode, root, '');
|
||||||
base,
|
return env.VITE_BASE || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defineAdminConfig(opts: AdminConfigOptions) {
|
||||||
|
return defineConfig(({ mode }): UserConfig => ({
|
||||||
|
base: resolveBase(mode, opts.root),
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
build: {
|
build: {
|
||||||
outDir: opts.outDir ?? path.resolve(opts.root, '../static/dist'),
|
outDir: opts.outDir ?? path.resolve(opts.root, '../static/dist'),
|
||||||
|
|
@ -33,7 +45,7 @@ export function defineAdminConfig(opts: AdminConfigOptions): UserConfig {
|
||||||
? { output: { manualChunks: opts.vendorChunks } }
|
? { output: { manualChunks: opts.vendorChunks } }
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuestConfigOptions extends AdminConfigOptions {
|
export interface GuestConfigOptions extends AdminConfigOptions {
|
||||||
|
|
@ -41,10 +53,9 @@ export interface GuestConfigOptions extends AdminConfigOptions {
|
||||||
entry?: string;
|
entry?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defineGuestConfig(opts: GuestConfigOptions): UserConfig {
|
export function defineGuestConfig(opts: GuestConfigOptions) {
|
||||||
const base = process.env.VITE_BASE || '/';
|
return defineConfig(({ mode }): UserConfig => ({
|
||||||
return {
|
base: resolveBase(mode, opts.root),
|
||||||
base,
|
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
build: {
|
build: {
|
||||||
outDir: opts.outDir ?? path.resolve(opts.root, '../static/guest/dist'),
|
outDir: opts.outDir ?? path.resolve(opts.root, '../static/guest/dist'),
|
||||||
|
|
@ -60,5 +71,5 @@ export function defineGuestConfig(opts: GuestConfigOptions): UserConfig {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
0
tests/e2e/__init__.py
Normal file
0
tests/e2e/__init__.py
Normal file
165
tests/e2e/conftest.py
Normal file
165
tests/e2e/conftest.py
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
"""
|
||||||
|
Fixture chain for the e2e password-setup test (issue #7, Option A1 — scoped).
|
||||||
|
|
||||||
|
Skips the host-provisioning side of `new-project.sh` (root, systemd, Apache,
|
||||||
|
Forgejo). Instead clones webapp-template into a tmp dir, builds it with
|
||||||
|
`VITE_BASE` pinned to a synthetic prefix, boots the binary on an ephemeral
|
||||||
|
port, and fronts it with the in-process PrefixStrippingProxy. The result
|
||||||
|
exercises the same contract that broke twice in production: the build
|
||||||
|
pipeline + the reverse-proxy prefix + the set-password flow.
|
||||||
|
|
||||||
|
Required environment / tooling on the test host:
|
||||||
|
- `WEBAPP_TEMPLATE_DIR` env var → path to a webapp-template source tree
|
||||||
|
(defaults to /home/git/webapp-template).
|
||||||
|
- cmake + make + a C++ toolchain.
|
||||||
|
- node + npm.
|
||||||
|
- A built webapp binary appears at `<build>/webapp`.
|
||||||
|
|
||||||
|
The build is cached per pytest session in `tmp_path_factory`'s root, so a
|
||||||
|
warm rerun is fast. To skip the build entirely, set
|
||||||
|
`WEBAPP_TEMPLATE_BUILD_DIR=/path/to/prebuilt-build` (must contain `webapp`
|
||||||
|
and `static/dist/index.html` with `/projects/tmp-foo/` baked into asset
|
||||||
|
URLs).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .proxy import PrefixStrippingProxy
|
||||||
|
|
||||||
|
PREFIX = "/projects/tmp-foo"
|
||||||
|
TEMPLATE_DIR_DEFAULT = "/home/git/webapp-template"
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_port(port: int, timeout: float = 30.0) -> None:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise RuntimeError(f"port {port} did not open within {timeout}s")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def webapp_template_src() -> Path:
|
||||||
|
src = Path(os.environ.get("WEBAPP_TEMPLATE_DIR", TEMPLATE_DIR_DEFAULT))
|
||||||
|
if not (src / "CMakeLists.txt").exists():
|
||||||
|
pytest.skip(f"webapp-template source not found at {src}")
|
||||||
|
return src
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def built_webapp(webapp_template_src: Path, tmp_path_factory) -> Path:
|
||||||
|
"""Returns a build directory containing `webapp` and a Vite bundle whose
|
||||||
|
asset URLs include the PREFIX. Honours WEBAPP_TEMPLATE_BUILD_DIR to skip
|
||||||
|
the build step entirely (CI provides a pre-built tree)."""
|
||||||
|
pre = os.environ.get("WEBAPP_TEMPLATE_BUILD_DIR")
|
||||||
|
if pre:
|
||||||
|
return Path(pre)
|
||||||
|
|
||||||
|
work = tmp_path_factory.mktemp("webapp-template-build")
|
||||||
|
# Shallow copy: source files only, skip node_modules + build/ if present.
|
||||||
|
for entry in webapp_template_src.iterdir():
|
||||||
|
if entry.name in {"build", "node_modules", ".git"}:
|
||||||
|
continue
|
||||||
|
dest = work / entry.name
|
||||||
|
if entry.is_dir():
|
||||||
|
shutil.copytree(entry, dest, symlinks=True,
|
||||||
|
ignore=shutil.ignore_patterns("node_modules", "build"))
|
||||||
|
else:
|
||||||
|
shutil.copy2(entry, dest)
|
||||||
|
|
||||||
|
# Pin VITE_BASE for the production build (matches what new-project.sh
|
||||||
|
# would write into a deployed project).
|
||||||
|
(work / "frontend").mkdir(exist_ok=True)
|
||||||
|
(work / "frontend" / ".env.production").write_text(f"VITE_BASE={PREFIX}/\n")
|
||||||
|
|
||||||
|
build_dir = work / "build"
|
||||||
|
build_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
env = {**os.environ, "VITE_BASE": f"{PREFIX}/"}
|
||||||
|
subprocess.run(
|
||||||
|
["cmake", "-DCMAKE_BUILD_TYPE=Release", ".."],
|
||||||
|
cwd=build_dir, env=env, check=True,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["make", "-j2"],
|
||||||
|
cwd=build_dir, env=env, check=True,
|
||||||
|
)
|
||||||
|
return build_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def boot_app(built_webapp: Path, tmp_path: Path) -> Iterator[dict]:
|
||||||
|
"""Spawns the built webapp binary on an ephemeral port. Returns
|
||||||
|
{port, data_dir, db_path, proc}. Tears down on test exit."""
|
||||||
|
binary = built_webapp / "webapp"
|
||||||
|
if not binary.exists():
|
||||||
|
pytest.skip(f"webapp binary not at {binary}")
|
||||||
|
|
||||||
|
port = _free_port()
|
||||||
|
data_dir = tmp_path / "data"
|
||||||
|
data_dir.mkdir()
|
||||||
|
db_path = tmp_path / "app.sqlite"
|
||||||
|
log = open(tmp_path / "webapp.log", "wb")
|
||||||
|
|
||||||
|
env = {**os.environ, "FEWO_ENCRYPTION_KEY": "x" * 64}
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[str(binary),
|
||||||
|
"--data-dir", str(built_webapp / ".." / "static"), # served from source tree
|
||||||
|
"--db", str(db_path),
|
||||||
|
"--port", str(port)],
|
||||||
|
env=env, stdout=log, stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_port(port)
|
||||||
|
yield {"port": port, "data_dir": data_dir, "db_path": db_path, "proc": proc,
|
||||||
|
"binary": binary}
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try: proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired: proc.kill()
|
||||||
|
log.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def proxy(boot_app: dict) -> Iterator[dict]:
|
||||||
|
"""Spawns the prefix-stripping proxy in front of the booted app."""
|
||||||
|
p = PrefixStrippingProxy("127.0.0.1", boot_app["port"], PREFIX)
|
||||||
|
p.start()
|
||||||
|
try:
|
||||||
|
yield {"port": p.port, "prefix": PREFIX, "base_url": f"http://127.0.0.1:{p.port}{PREFIX}"}
|
||||||
|
finally:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_token(boot_app: dict) -> str:
|
||||||
|
"""Issues a one-shot password-setup token via the binary's CLI mode."""
|
||||||
|
out = subprocess.run(
|
||||||
|
[str(boot_app["binary"]),
|
||||||
|
"--db", str(boot_app["db_path"]),
|
||||||
|
"--issue-admin-reset", "tester"],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
)
|
||||||
|
# `--issue-admin-reset` prints the raw token on stdout (last line).
|
||||||
|
token = out.stdout.strip().splitlines()[-1].strip()
|
||||||
|
if not token or len(token) < 16:
|
||||||
|
raise RuntimeError(f"unexpected --issue-admin-reset output: {out.stdout!r}")
|
||||||
|
return token
|
||||||
103
tests/e2e/proxy.py
Normal file
103
tests/e2e/proxy.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""
|
||||||
|
PrefixStrippingProxy — minimal in-process HTTP reverse proxy for e2e tests.
|
||||||
|
|
||||||
|
Mirrors the production Apache vhost contract (see `templates/projects-*.conf`):
|
||||||
|
|
||||||
|
GET /projects/<name>/foo → GET /foo with `X-Forwarded-Prefix: /projects/<name>`
|
||||||
|
|
||||||
|
Used by the e2e password-setup test to exercise the same path-rewriting
|
||||||
|
flow that broke twice in production (oatpp-authkit query-string 401, and
|
||||||
|
the VITE_BASE blank-page bug). Plain stdlib only — no third-party deps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
_HOP_BY_HOP = {
|
||||||
|
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||||
|
"te", "trailers", "transfer-encoding", "upgrade", "content-length", "host",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PrefixStrippingProxy:
|
||||||
|
"""Forwards every request from `:proxy_port/<prefix>/...` to the backend.
|
||||||
|
|
||||||
|
Backend sees the un-prefixed path plus an `X-Forwarded-Prefix` header,
|
||||||
|
matching what Apache's `ProxyPass /projects/<name>/ http://127.0.0.1:N/`
|
||||||
|
does in production.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, target_host: str, target_port: int, prefix: str):
|
||||||
|
self.target = f"http://{target_host}:{target_port}"
|
||||||
|
self.prefix = "/" + prefix.strip("/")
|
||||||
|
self.server: Optional[ThreadingHTTPServer] = None
|
||||||
|
self.thread: Optional[threading.Thread] = None
|
||||||
|
self.port: Optional[int] = None
|
||||||
|
|
||||||
|
def start(self) -> int:
|
||||||
|
proxy = self
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self): proxy._forward(self)
|
||||||
|
def do_POST(self): proxy._forward(self)
|
||||||
|
def do_PUT(self): proxy._forward(self)
|
||||||
|
def do_DELETE(self): proxy._forward(self)
|
||||||
|
def do_PATCH(self): proxy._forward(self)
|
||||||
|
def log_message(self, *a, **kw): pass
|
||||||
|
|
||||||
|
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
self.port = self.server.server_address[1]
|
||||||
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
return self.port
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if self.server:
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
|
||||||
|
def _strip_prefix(self, path: str) -> str:
|
||||||
|
if path.startswith(self.prefix + "/"):
|
||||||
|
return path[len(self.prefix):]
|
||||||
|
if path == self.prefix:
|
||||||
|
return "/"
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _forward(self, h: BaseHTTPRequestHandler) -> None:
|
||||||
|
upstream_path = self._strip_prefix(h.path)
|
||||||
|
body_len = int(h.headers.get("Content-Length") or 0)
|
||||||
|
body = h.rfile.read(body_len) if body_len else None
|
||||||
|
req = urllib.request.Request(self.target + upstream_path, method=h.command, data=body)
|
||||||
|
for k, v in h.headers.items():
|
||||||
|
if k.lower() in _HOP_BY_HOP:
|
||||||
|
continue
|
||||||
|
req.add_header(k, v)
|
||||||
|
req.add_header("X-Forwarded-Prefix", self.prefix)
|
||||||
|
req.add_header("X-Forwarded-Host", h.headers.get("Host", "127.0.0.1"))
|
||||||
|
req.add_header("X-Forwarded-Proto", "http")
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = urllib.request.urlopen(req, timeout=15)
|
||||||
|
status, headers, content = r.status, r.headers, r.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
status, headers, content = e.code, e.headers, e.read()
|
||||||
|
except Exception as exc:
|
||||||
|
h.send_response(502)
|
||||||
|
msg = f"proxy upstream error: {exc}".encode()
|
||||||
|
h.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
h.send_header("Content-Length", str(len(msg)))
|
||||||
|
h.end_headers()
|
||||||
|
h.wfile.write(msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
h.send_response(status)
|
||||||
|
for k, v in headers.items():
|
||||||
|
if k.lower() in _HOP_BY_HOP:
|
||||||
|
continue
|
||||||
|
h.send_header(k, v)
|
||||||
|
h.send_header("Content-Length", str(len(content)))
|
||||||
|
h.end_headers()
|
||||||
|
h.wfile.write(content)
|
||||||
87
tests/e2e/test_password_setup.py
Normal file
87
tests/e2e/test_password_setup.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""
|
||||||
|
End-to-end test for the initial password-setup flow on a scaffolded project
|
||||||
|
(webapp-scaffold #7, Option A1).
|
||||||
|
|
||||||
|
Reproduces the contract that broke twice in production:
|
||||||
|
1. oatpp-authkit AuthInterceptor rejected `/set-password?token=…` with 401
|
||||||
|
because the public-path check compared against the request-target
|
||||||
|
(which includes the query string). v0.3.3 / commit 46971ac.
|
||||||
|
2. Newly-scaffolded projects shipped with `VITE_BASE='/'` so SPA assets
|
||||||
|
404'd behind the `/projects/<name>/` Apache prefix → blank page.
|
||||||
|
webapp-scaffold v0.3.6 / commit b1a13b8.
|
||||||
|
|
||||||
|
Both regressions slipped past the existing unit/component test layers
|
||||||
|
because none of them exercises the *deployed* shape of a scaffolded
|
||||||
|
project. The fixtures in `conftest.py` recreate that shape inline:
|
||||||
|
clone webapp-template, build with VITE_BASE pinned, boot the binary,
|
||||||
|
front it with the in-process PrefixStrippingProxy, follow the email
|
||||||
|
link.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _http_get(url: str, *, headers=None) -> tuple[int, str, dict]:
|
||||||
|
req = urllib.request.Request(url, headers=headers or {"Accept": "text/html"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
return r.status, r.read().decode("utf-8", "replace"), dict(r.headers)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read().decode("utf-8", "replace"), dict(e.headers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_password_link_returns_html(proxy, admin_token):
|
||||||
|
"""Regression for oatpp-authkit query-string 401: the link must serve
|
||||||
|
the SPA HTML, not a JSON 401."""
|
||||||
|
url = f"{proxy['base_url']}/set-password?token={admin_token}"
|
||||||
|
status, body, headers = _http_get(url)
|
||||||
|
assert status == 200, f"got {status}: {body[:200]}"
|
||||||
|
assert "text/html" in headers.get("Content-Type", "").lower(), headers
|
||||||
|
# The SPA shell loads the bundle; the form itself is rendered by JS,
|
||||||
|
# so we just assert the shell is well-formed.
|
||||||
|
assert "<html" in body.lower() or "<!doctype" in body.lower(), body[:200]
|
||||||
|
|
||||||
|
|
||||||
|
def test_assets_resolve_through_prefix(proxy, admin_token):
|
||||||
|
"""Regression for VITE_BASE blank-page bug: every asset URL referenced
|
||||||
|
by the served HTML must resolve under the prefix."""
|
||||||
|
url = f"{proxy['base_url']}/set-password?token={admin_token}"
|
||||||
|
_, body, _ = _http_get(url)
|
||||||
|
|
||||||
|
asset_urls = re.findall(
|
||||||
|
r'<(?:script|link)[^>]*\b(?:src|href)\s*=\s*["\']([^"\']+)["\']',
|
||||||
|
body, re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Only check absolute-path asset URLs (skip cross-origin, data:, etc.).
|
||||||
|
local = [u for u in asset_urls if u.startswith("/")]
|
||||||
|
assert local, f"no local asset URLs found in HTML: {body[:300]}"
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
for path in local:
|
||||||
|
# The asset URL is browser-side — already includes the proxy prefix.
|
||||||
|
# Fetch it through the proxy so we exercise the same path the
|
||||||
|
# browser would.
|
||||||
|
full = f"http://127.0.0.1:{proxy['port']}{path}"
|
||||||
|
s, _, _ = _http_get(full)
|
||||||
|
if s != 200:
|
||||||
|
failures.append(f"{path} → {s}")
|
||||||
|
|
||||||
|
assert not failures, "asset URLs failed to resolve through the prefix:\n " \
|
||||||
|
+ "\n ".join(failures)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_path_still_returns_json_401(proxy):
|
||||||
|
"""Sanity check the content-negotiation: an /api/ call without a token
|
||||||
|
must still get JSON 401 (browser navigation gets HTML, but API stays JSON)."""
|
||||||
|
full = f"http://127.0.0.1:{proxy['port']}{proxy['prefix']}/api/users"
|
||||||
|
req = urllib.request.Request(full, headers={"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=5)
|
||||||
|
pytest.fail("expected 401 from unauth /api/ call")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
assert e.code == 401
|
||||||
|
assert "application/json" in e.headers.get("Content-Type", "").lower()
|
||||||
77
tests/test_inject_hashed_filenames.py
Normal file
77
tests/test_inject_hashed_filenames.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""Tests for bin/inject-hashed-filenames.py rewrite() (#3).
|
||||||
|
|
||||||
|
Pinned behaviour: the rewrite is tag-aware — only `<script src="…">` and
|
||||||
|
`<link href="…">` attribute values are replaced. Inert occurrences of the
|
||||||
|
old src in HTML comments, JSON literals, or unrelated attributes must be
|
||||||
|
left alone (the previous `html.replace` was substring-blind).
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
SCRIPT = os.path.join(os.path.dirname(HERE), "bin", "inject-hashed-filenames.py")
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("ihf", SCRIPT)
|
||||||
|
ihf = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules["ihf"] = ihf
|
||||||
|
spec.loader.exec_module(ihf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rewrites_script_src_double_quoted():
|
||||||
|
html = '<script src="/static/dist/app.js"></script>'
|
||||||
|
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||||
|
assert n == 1
|
||||||
|
assert out == '<script src="/static/dist/app.abc123.js"></script>'
|
||||||
|
|
||||||
|
|
||||||
|
def test_rewrites_script_src_single_quoted():
|
||||||
|
html = "<script src='/static/dist/app.js'></script>"
|
||||||
|
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||||
|
assert n == 1
|
||||||
|
assert "/static/dist/app.abc123.js" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_rewrites_link_href():
|
||||||
|
html = '<link rel="stylesheet" href="/static/dist/app.css">'
|
||||||
|
out, n = ihf.rewrite(html, "/static/dist/app.css", "/static/dist/app.abc123.css")
|
||||||
|
assert n == 1
|
||||||
|
assert '/static/dist/app.abc123.css' in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_rewrite_inside_html_comment():
|
||||||
|
html = '<!-- old script was at /static/dist/app.js --><script src="/other.js"></script>'
|
||||||
|
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||||
|
assert n == 0
|
||||||
|
assert "/static/dist/app.js" in out
|
||||||
|
assert "/static/dist/app.abc123.js" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_rewrite_inside_json_literal():
|
||||||
|
html = '<pre>{ "src": "/static/dist/app.js" }</pre>'
|
||||||
|
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||||
|
assert n == 0
|
||||||
|
assert out == html
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_rewrite_unrelated_attribute():
|
||||||
|
html = '<img data-bundle="/static/dist/app.js">'
|
||||||
|
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||||
|
assert n == 0
|
||||||
|
assert out == html
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_rewrite_anchor_href():
|
||||||
|
# Even though <a href="…"> is a `href` attribute, it isn't a <link>.
|
||||||
|
html = '<a href="/static/dist/app.js">debug link</a>'
|
||||||
|
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||||
|
assert n == 0
|
||||||
|
assert out == html
|
||||||
|
|
||||||
|
|
||||||
|
def test_rewrites_with_extra_attributes_around_src():
|
||||||
|
html = '<script type="module" src="/static/dist/app.js" defer></script>'
|
||||||
|
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||||
|
assert n == 1
|
||||||
|
assert '/static/dist/app.abc123.js' in out
|
||||||
|
assert 'type="module"' in out and 'defer' in out
|
||||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: false,
|
||||||
|
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue