Compare commits

...

11 commits
v0.3.4 ... main

Author SHA1 Message Date
Claude
b00a4320a7 core-fetch: baseUrl accepts () => string for lazy resolution
Lets consumers pass `() => import.meta.env.BASE_URL` without orval's CJS
bundle path blowing up on the top-level `import.meta` reference. The
getter is invoked at request time rather than factory time, so the
mutator file can be loaded by orval (which bundles to CJS) without
evaluating `import.meta`.

Closes uwe.admin/webapp-template#21 (scaffold side).

Bump to v0.5.0 — additive change (string form still works) but new
shape for `CoreFetchOptions.baseUrl` is a public API change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:49:06 +02:00
8677faf54b postprocess-openapi: also fix type: Any and duplicate params
Two more oatpp 1.3 spec quirks surfaced when fewo-webapp's #469
increment 2 wired up Orval's strict Zod codegen on top of the v0.4.1
`required: []` strip:

- `"type": "Any"` is emitted for `oatpp::Any` / `oatpp::Fields` fields.
  OpenAPI 3.0 has no "Any" type — the empty schema `{}` (or absence of
  `type`) is the canonical "any value" form. Strip the offending key
  recursively across the whole spec.
- Some endpoints emit the same path parameter twice (same `name` + same
  `in: path`). Dedupe by `(name, in)` preserving first occurrence.

These were caught only by `@scalar/openapi-parser`'s strict mode (used by
Orval's Zod project); the fetch-client project tolerated them. Without
both fixes, fewoZod fails with `must NOT have duplicate items` and
`must match exactly one schema in oneOf` per affected component.

Bumps to v0.4.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:25:49 +02:00
4c4d52e3de postprocess-openapi: strip empty required: [] arrays
oatpp 1.3 emits `"required": []` on schemas with no required fields.
OpenAPI 3.0.x rejects this with "must NOT have fewer than 1 items",
which silently breaks strict consumers. Orval's typed fetch client
swallowed it (the violation surfaces only as a non-fatal warning), but
Orval's Zod generator (added downstream in fewo-webapp's #469 increment 2)
fails hard:

  must NOT have fewer than 1 items at /components/schemas/<DTO>/required
  must have required property '$ref'                  at /components/schemas/<DTO>
  must match exactly one schema in oneOf              at /components/schemas/<DTO>

83 of fewo-webapp's 198 DTOs trip this. Stripping the empty array (absence
of the keyword has the same semantics) is the spec-compliant fix.

Bumps to v0.4.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:22:30 +02:00
5ee6894916 #7: Add tests/e2e/ for the initial password-setup flow (Option A1)
Closes the integration gap that let two prior regressions ship:
  1. oatpp-authkit query-string 401 (v0.3.3 / commit 46971ac)
  2. VITE_BASE blank page (v0.3.6 / commit b1a13b8)

A1 scope: skips the host-provisioning side of new-project.sh (root,
systemd, Apache, Forgejo). Instead clones webapp-template into a tmp
dir, builds with VITE_BASE pinned to /projects/tmp-foo/, boots the
binary on an ephemeral port, fronts it with an in-process
PrefixStrippingProxy that mirrors the production Apache vhost. Tests
then drive the same flow a real user would.

Files:
- tests/e2e/proxy.py — stdlib-only reverse proxy (~100 LOC,
  ThreadingHTTPServer + urllib). Strips the /projects/<name>/ prefix
  and sets X-Forwarded-Prefix exactly like Apache's ProxyPass.
- tests/e2e/conftest.py — webapp_template_src / built_webapp /
  boot_app / proxy / admin_token fixtures. Honours
  WEBAPP_TEMPLATE_DIR + WEBAPP_TEMPLATE_BUILD_DIR env vars so CI can
  point at a pre-built tree to skip the build step.
- tests/e2e/test_password_setup.py — three assertions per #7:
    - /set-password?token=… returns HTML, not JSON 401
    - every <script src>/<link href> resolves through the prefix
    - /api/* still returns JSON 401 (sanity-check negotiation)

No Selenium dependency — the assertions are HTTP-level and reliable
in CI without a Chrome/Geckodriver setup. Selenium can be added later
for actual form-submission coverage if needed.

Test runs are skipped automatically when webapp-template source is
absent, so the suite is safe to drop into any pytest invocation.

Closes #7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:23:30 +02:00
fd451fd452 #5: createCoreFetch default responseShape flipped to 'body'
Orval's `client: 'fetch'` (with the no-`includeHttpResponseReturnType`
default) emits mutator signatures expecting the parsed body — so making
`'body'` the default means the no-arg `createCoreFetch()` form is the
right answer for the typical Orval-driven derived project. Callers that
need Response metadata opt in via `responseShape: 'wrapped'`.

Aligns README + docstring + the inline default. Tests updated to assert
default-`'body'` and explicit-`'wrapped'`.

Bump to 0.4.0 (BREAKING — default flipped). Downstream consumers on the
default need to either (a) add explicit `responseShape: 'wrapped'` to
preserve old behaviour, or (b) migrate callers to the body shape.
fewo-webapp's existing `responseShape: 'body'` override is now redundant
and will be dropped in a follow-up.

Closes #5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:47:40 +02:00
90c5ca2248 #4: vitest harness + suites for createCoreFetch / createI18n / useI18nStore
Bootstrap vitest with jsdom + @testing-library/react. 25 tests covering:
- core-fetch.test.ts (14): wrapped vs body shape, 204, non-JSON 2xx text,
  401/409 hooks (incl. decorate), formatError override, network failure +
  onNetworkFailure, sync queue + non-mutating bypass, request shape
  (X-Requested-With, credentials: include), baseUrl trailing-slash strip.
- i18n.test.ts (10): resolver chain (locale-tone → locale → default →
  raw), once-per-(key,locale,tone) onMiss firing + flip on locale change,
  subscribe/notify with same-value no-op + unsubscribe, getSnapshot.
- i18n-react.test.tsx (1): useI18nStore re-renders on locale change.

prepare: drops `|| true` per audit — `tsc && vitest run` so publish is
gated on tests passing.

Bump to 0.3.7.

Closes #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:45:31 +02:00
f4f9289bdc gitignore: drop accidentally-committed __pycache__/.pytest_cache
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:38:32 +02:00
b3b2903c75 #3: inject-hashed-filenames.py — tag-aware HTML rewrite
Replace substring `html.replace(old_src, new_src)` with a regex anchored
to <script src="…"> / <link href="…"> attribute values. Inert occurrences
in comments, JSON literals, or unrelated attributes are left alone.

Loud warning (stderr) when zero matches occur — previously the script
silently skipped a typo'd old_src.

Also rewrites <link href> in the same pass so adjacent CSS hashing doesn't
need a follow-up edit.

Tests: tests/test_inject_hashed_filenames.py covers happy path (both quote
styles, extra attributes), inert-substring cases (comment, JSON literal,
data-attr, anchor href), and link-href rewriting.

Closes #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:38:19 +02:00
b1a13b83fd #6: Use Vite's loadEnv() for VITE_BASE so .env.production actually wins
defineAdminConfig / defineGuestConfig were reading process.env.VITE_BASE,
but Vite does NOT populate process.env from .env files at config-evaluation
time — those go into import.meta.env for the client bundle only. So the
VITE_BASE that new-project.sh writes to frontend/.env.production was
silently ignored, base fell back to '/', and SPA assets 404'd behind the
Apache /projects/<name>/ proxy prefix (blank page on every public route).

Switch both factories to Vite's defineConfig + loadEnv pattern. A
process.env.VITE_BASE override still wins so CI invocations that
explicitly export the variable keep working.

Bumps to 0.3.6.

Closes #6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:36:23 +02:00
5b0bec8850 #2: fetch-openapi.sh — pass $OUT via sys.argv, not f-string in heredoc
Closes #2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:34:02 +02:00
Uwe Schuster
b14b8188fe v0.3.5: readBody tries .json() without clone() first
Some test mocks expose .json() but not .clone() — cloning throws on
object mocks. Try .json() directly, fall back to .text(). Real Response
objects are unaffected (calling .json() twice would throw, but we only
call it once since we're on the error path).
2026-04-21 22:33:01 +02:00
18 changed files with 3453 additions and 206 deletions

3
.gitignore vendored
View file

@ -2,3 +2,6 @@ node_modules/
dist/
*.tgz
.DS_Store
__pycache__/
.pytest_cache/
*.pyc

View file

@ -43,12 +43,16 @@ webapp-scaffold-postprocess-openapi && \
orval
```
## `createCoreFetch` (v0.2)
## `createCoreFetch` (v0.4 — default flipped to `'body'`)
Orval's `client: 'fetch'` calls a mutator `coreFetch<T>(url, init)` and
expects `{data, status, headers}` back. `createCoreFetch(opts)` returns
such a function, with credentials/CSRF headers / 204 / content-type
handling already wired, and hooks for project-specific concerns:
Orval's `client: 'fetch'` calls a mutator `coreFetch<T>(url, init)`. The
return shape is configurable; **the default is `'body'`** (returns the
parsed JSON body directly, matching Orval's
`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
import { createCoreFetch } from '@uschuster/webapp-scaffold/core-fetch';

View file

@ -29,9 +29,10 @@ curl -fsS "${AUTH_HEADER[@]}" "$URL" -o "$OUT"
# Verify it's actually JSON with an `openapi` key — oatpp sometimes returns
# HTML on 401 which would silently land here otherwise.
python3 -c "
python3 - "$OUT" <<'PY'
import json, sys
d = json.load(open('$OUT'))
assert 'openapi' in d, 'not an OpenAPI spec (missing \"openapi\" key)'
print(f\" openapi={d['openapi']}, paths={len(d.get('paths', {}))}\")
"
path = sys.argv[1]
d = json.load(open(path))
assert 'openapi' in d, 'not an OpenAPI spec (missing "openapi" key)'
print(f" openapi={d['openapi']}, paths={len(d.get('paths', {}))}")
PY

View file

@ -26,9 +26,29 @@ containing the config file's parent chain → `$PWD`).
"""
import json
import os
import re
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:
if not os.path.exists(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}"
with open(html_path) as f:
html = f.read()
if old_src not in html:
print(f"skip: {old_src!r} not in {html_path}")
new_html, count = rewrite(html, old_src, new_src)
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
with open(html_path, "w") as f:
f.write(html.replace(old_src, new_src))
print(f"{old_src} -> {new_src}")
f.write(new_html)
print(f"{old_src} -> {new_src} ({count} occurrence{'s' if count != 1 else ''})")
return
print(f"skip: no isEntry row in {manifest_path}")

View file

@ -43,5 +43,60 @@ for path, methods in paths.items():
op.setdefault("operationId", op_id(method, path))
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))
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

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "@uschuster/webapp-scaffold",
"version": "0.3.4",
"version": "0.5.0",
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
"type": "module",
"bin": {
@ -32,12 +32,23 @@
},
"files": ["bin/", "dist/", "src/", "templates/", "README.md", "LICENSE"],
"scripts": {
"prepare": "tsc || true"
"prepare": "tsc && vitest run",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0"
"typescript": "^5.0.0",
"@types/node": "^22.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": {
"vite": "^6.0.0",

195
src/core-fetch.test.ts Normal file
View 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',
]);
});
});

View file

@ -3,10 +3,12 @@
// Orval's `client: 'fetch'` emits functions that call
// coreFetch<T>(url, init)
// and expect one of:
// • { data, status, headers } back (responseShape: 'wrapped', default —
// matches orval's default `includeHttpResponseReturnType: true`)
// • the parsed body directly (responseShape: 'body' — matches orval's
// `includeHttpResponseReturnType: false`, fewo's convention)
// • 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)
//
// Baseline wired in:
// • credentials: 'include' — cookies always sent
@ -26,10 +28,18 @@ export interface CoreFetchRequest {
}
export interface CoreFetchOptions {
/** Prefix for every request; strip trailing slash. */
baseUrl?: string;
/**
* Prefix for every request; trailing slash stripped at resolve time.
*
* Accepts a string OR a getter so consumers that derive the base from
* `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 | (() => string);
/** `'wrapped'` returns { data, status, headers } (default); `'body'` returns the parsed body. */
/** `'body'` returns the parsed body (default); `'wrapped'` returns { data, status, headers }. */
responseShape?: ResponseShape;
/**
@ -95,13 +105,17 @@ export type CoreFetch = CoreFetchWrapped & CoreFetchBody;
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
const base = (opts.baseUrl ?? '').replace(/\/$/, '');
// Lazy base resolution — see CoreFetchOptions.baseUrl JSDoc.
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 ?? 'wrapped';
const shape: ResponseShape = opts.responseShape ?? 'body';
function wrap(data: unknown, status: number, headers: Headers): unknown {
return shape === 'body' ? data : { data, status, headers };
@ -133,7 +147,7 @@ export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
let r: Response;
try {
r = await callFetch(`${base}${url}`, {
r = await callFetch(`${resolveBase()}${url}`, {
credentials: 'include',
...init,
headers,
@ -191,12 +205,18 @@ function safeJson(text: string): unknown | null {
* 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.clone().json();
const j = await r.json();
return [JSON.stringify(j), j];
} catch {
const text = await r.text().catch(() => '');
return [text, safeJson(text)];
try {
const text = await r.text();
return [text, safeJson(text)];
} catch {
return ['', null];
}
}
}

31
src/i18n-react.test.tsx Normal file
View 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
View 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');
});
});

View file

@ -6,8 +6,15 @@
// Both honour the same Apache-proxy-prefix baseUrl convention and keep
// their outputs under a conventional path so the `StaticController` and
// 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 path from 'path';
@ -20,10 +27,15 @@ export interface AdminConfigOptions {
outDir?: string;
}
export function defineAdminConfig(opts: AdminConfigOptions): UserConfig {
const base = process.env.VITE_BASE || '/';
return {
base,
function resolveBase(mode: string, root: string): string {
if (process.env.VITE_BASE) return process.env.VITE_BASE;
const env = loadEnv(mode, root, '');
return env.VITE_BASE || '/';
}
export function defineAdminConfig(opts: AdminConfigOptions) {
return defineConfig(({ mode }): UserConfig => ({
base: resolveBase(mode, opts.root),
plugins: [react()],
build: {
outDir: opts.outDir ?? path.resolve(opts.root, '../static/dist'),
@ -33,7 +45,7 @@ export function defineAdminConfig(opts: AdminConfigOptions): UserConfig {
? { output: { manualChunks: opts.vendorChunks } }
: undefined,
},
};
}));
}
export interface GuestConfigOptions extends AdminConfigOptions {
@ -41,10 +53,9 @@ export interface GuestConfigOptions extends AdminConfigOptions {
entry?: string;
}
export function defineGuestConfig(opts: GuestConfigOptions): UserConfig {
const base = process.env.VITE_BASE || '/';
return {
base,
export function defineGuestConfig(opts: GuestConfigOptions) {
return defineConfig(({ mode }): UserConfig => ({
base: resolveBase(mode, opts.root),
plugins: [react()],
build: {
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
View file

165
tests/e2e/conftest.py Normal file
View 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
View 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)

View 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()

View 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
View 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'],
},
});