Compare commits

...

8 commits
v0.3.6 ... 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
16 changed files with 3416 additions and 187 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

@ -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.6",
"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,

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

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