From bc5855c8c6e093c70b9531961e5f0700f5581f80 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:29:00 +0300 Subject: [PATCH 1/2] test(forms): run the starter's form client against the public forms routes The client every delivered site ships (copied as a fixture) now runs against the real config and submit handlers: rendered field names, honeypot, Turnstile token, success, validation and captcha errors, and 402. --- tests/fixtures/starter/README.md | 8 + tests/fixtures/starter/embed.ts | 792 ++++++++++++++++++ .../starter-form-contract.integration.test.ts | 233 ++++++ 3 files changed, 1033 insertions(+) create mode 100644 tests/fixtures/starter/README.md create mode 100644 tests/fixtures/starter/embed.ts create mode 100644 tests/integration/starter-form-contract.integration.test.ts diff --git a/tests/fixtures/starter/README.md b/tests/fixtures/starter/README.md new file mode 100644 index 00000000..e26424db --- /dev/null +++ b/tests/fixtures/starter/README.md @@ -0,0 +1,8 @@ +# Starter fixtures + +`embed.ts` is a byte-for-byte copy of the browser client every site delivered by Contentrain Migrate +ships (`packages/writer/starter/src/lib/studio/embed.ts` in the migrate repo, emitted from +`@contentrain/emitter-astro`). `tests/integration/starter-form-contract.integration.test.ts` runs it against +Studio's real public forms routes, so a change to either side that breaks the other fails here. + +Refresh it by copying the file again when the starter's client changes; do not edit it in place. diff --git a/tests/fixtures/starter/embed.ts b/tests/fixtures/starter/embed.ts new file mode 100644 index 00000000..3f218427 --- /dev/null +++ b/tests/fixtures/starter/embed.ts @@ -0,0 +1,792 @@ +// Emitted by @contentrain/emitter-astro — the runtime-component client. +// +// Comments and forms are the two regions of a migrated site that need a live +// service. This module is the browser side of the provider's PUBLIC API +// (Studio: /api/forms/v1 and /api/comments/v1) — the same contract that +// @contentrain/query/cdn implements, inlined so the generated site depends on +// nothing but Astro. No credential travels with a request: the endpoints are +// unauthenticated by design, and a page cannot keep a secret. +// +// Rendering helpers are pure (strings in, HTML out, escaped by default) so the +// mount functions at the bottom are the only code that touches the DOM. + +export interface Runtime { + base_url: string + project_id: string +} + +export interface EntryRef { + model_id: string + entry_id: string + locale?: string +} + +export class EmbedError extends Error { + status: number + /** The API's machine code (`data.code`), when the body carries one — e.g. `payment_required`. */ + code?: string + constructor(status: number, message: string, code?: string) { + super(message) + this.name = 'EmbedError' + this.status = status + if (code) this.code = code + } +} + +/** + * The workspace's subscription is inactive (Studio answers 402 + * `payment_required` on the public forms and comments endpoints). Not the + * visitor's problem and not transient: no retry helps until the owner updates + * billing. + */ +export function isPaymentRequired(error: unknown): boolean { + return error instanceof EmbedError && (error.status === 402 || error.code === 'payment_required') +} + +/** + * Take a widget off the page without a word to the visitor — the message is + * written for the workspace owner, who finds it in the console. The rest of + * the page is untouched. + */ +export function hideUnavailable(host: HTMLElement, what: 'form' | 'comments'): void { + host.innerHTML = '' + host.hidden = true + // A site stylesheet that sets display on the element would override hidden. + host.style.display = 'none' + console.debug('[contentrain] ' + what + ' hidden: the workspace subscription is inactive (402 payment_required). The workspace owner needs to update billing in Contentrain Studio.') +} + +/** + * Visitor-facing text. The component passes the page's text from the site's + * ui-strings dictionary (`data-strings`); these are the defaults it falls + * back to, key for key. + */ +export const strings = { + loading: 'Loading…', + send: 'Send', + sending: 'Sending…', + commentsTitle: 'Comments', + noComments: 'No comments yet.', + closed: 'Comments are closed.', + postComment: 'Post comment', + reply: 'Reply', + replyingTo: 'Replying to', + cancel: 'Cancel', + loadMore: 'Load more comments', + moderator: 'Moderator', + pending: 'Thank you — your comment is awaiting moderation.', + posted: 'Your comment has been posted.', + name: 'Name', + email: 'Email', + emailNote: '(never shown)', + website: 'Website', + comment: 'Comment', + failed: 'Something went wrong. Please try again.', + honeypot: 'Leave this field empty', +} + +export const esc = (value: unknown): string => + String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + +// ─── Transport ─── + +/** `base` without a trailing slash, segments URL-encoded, empty query values dropped. */ +export function publicUrl(base: string, segments: string[], query?: Record): string { + const path = segments.map(s => encodeURIComponent(s)).join('/') + const params = new URLSearchParams() + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined && value !== '') params.set(key, String(value)) + } + const qs = params.toString() + return base.replace(/\/+$/, '') + '/' + path + (qs ? '?' + qs : '') +} + +async function failure(res: Response): Promise { + const text = await res.text().catch(() => '') + let message = text || 'Request failed' + let code: string | undefined + try { + const parsed = JSON.parse(text) as { message?: unknown, statusMessage?: unknown, data?: { code?: unknown } } + const m = parsed.message ?? parsed.statusMessage + if (typeof m === 'string' && m) message = m + if (typeof parsed.data?.code === 'string') code = parsed.data.code + } + catch { + // not JSON — the raw text is the message + } + return new EmbedError(res.status, message, code) +} + +export async function getJson(url: string): Promise { + const res = await fetch(url) + if (!res.ok) throw await failure(res) + return (await res.json()) as T +} + +export async function postJson(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) throw await failure(res) + return (await res.json()) as T +} + +export const formsRoot = (rt: Runtime): string => rt.base_url.replace(/\/+$/, '') + '/api/forms/v1' +export const commentsRoot = (rt: Runtime): string => rt.base_url.replace(/\/+$/, '') + '/api/comments/v1' + +/** The hidden input Cloudflare Turnstile adds to the enclosing form. */ +export const CAPTCHA_FIELD = 'cf-turnstile-response' + +// ─── Forms ─── + +export interface FieldDef { + type: string + required?: boolean + label?: string + options?: string[] + min?: number + max?: number + pattern?: string + default?: unknown +} + +export interface FormConfig { + modelId: string + locale: string + /** Exposed fields only, keyed by field id. */ + fields: Record + captcha: 'turnstile' | null + captchaSiteKey: string | null + successMessage?: string + honeypotField: string | null +} + +export interface FieldError { + field: string + message: string +} + +export interface FormSubmitResult { + success: boolean + message?: string + errors?: FieldError[] +} + +/** The documented request body: values under `data`, control fields beside it. */ +export interface FormPayload { + data: Record + captchaToken?: string + _hp?: string +} + +export function fetchFormConfig(rt: Runtime, model: string): Promise { + return getJson(publicUrl(formsRoot(rt), [rt.project_id, model, 'config'])) +} + +export function submitForm(rt: Runtime, model: string, payload: FormPayload): Promise { + return postJson(publicUrl(formsRoot(rt), [rt.project_id, model, 'submit']), payload) +} + +function coerce(def: FieldDef, value: string): unknown { + switch (def.type) { + case 'number': + case 'integer': + case 'decimal': + case 'percent': + case 'rating': + return value === '' ? '' : Number(value) + case 'boolean': + return value === 'on' || value === 'true' + default: + return value + } +} + +/** + * Form entries (`new FormData(form)`) → request body. Only exposed fields + * reach `data`; the captcha token and the honeypot value travel beside it, so + * a control field can never collide with a model field. Files are skipped — + * uploads are not part of the public contract. + */ +export function formPayload( + entries: Iterable<[string, unknown]>, + config: { fields: Record, honeypotField: string | null }, +): FormPayload { + const payload: FormPayload = { data: {} } + for (const [name, raw] of entries) { + if (typeof raw !== 'string') continue + if (name === CAPTCHA_FIELD) { + if (raw) payload.captchaToken = raw + continue + } + if (config.honeypotField && name === config.honeypotField) { + payload._hp = raw + continue + } + const def = config.fields[name] + if (!def) continue + payload.data[name] = coerce(def, raw) + } + return payload +} + +export function labelFor(id: string, def: FieldDef): string { + if (def.label) return def.label + const words = id.replace(/[_-]+/g, ' ').trim() + return words.charAt(0).toUpperCase() + words.slice(1) +} + +/** One input for one exposed field, chosen by field type. */ +export function fieldControl(id: string, def: FieldDef, prefix = 'cr-field'): string { + const common + = ' id="' + esc(prefix + '-' + id) + '" name="' + esc(id) + '"' + + (def.required ? ' required' : '') + + (def.pattern ? ' pattern="' + esc(def.pattern) + '"' : '') + const range + = (def.min !== undefined ? ' min="' + esc(def.min) + '"' : '') + + (def.max !== undefined ? ' max="' + esc(def.max) + '"' : '') + const length = def.max !== undefined ? ' maxlength="' + esc(def.max) + '"' : '' + switch (def.type) { + case 'text': + case 'markdown': + case 'richtext': + case 'code': + return '' + case 'select': { + const blank = def.required ? '' : '' + const options = (def.options ?? []).map(o => '').join('') + return '' + blank + options + '' + } + case 'boolean': + return '' + case 'number': + case 'integer': + case 'rating': + return '' + case 'decimal': + case 'percent': + return '' + case 'email': + return '' + case 'url': + return '' + case 'phone': + return '' + case 'date': + return '' + case 'datetime': + return '' + case 'color': + return '' + default: + return '' + } +} + +export function honeypotHtml(field: string | null): string { + if (!field) return '' + return ( + '' + ) +} + +export function captchaHtml(captcha: 'turnstile' | null, siteKey: string | null): string { + if (captcha !== 'turnstile' || !siteKey) return '' + return '
' +} + +export function errorsHtml(errors: FieldError[]): string { + return ( + '' + ) +} + +/** The whole form for a public form config: one control per exposed field, honeypot, captcha, submit. */ +export function formHtml(config: FormConfig, prefix = 'cr-field'): string { + const fields = Object.entries(config.fields) + .map(([id, def]) => { + const control = fieldControl(id, def, prefix) + const label = '' + return '

' + (def.type === 'boolean' ? control + ' ' + label : label + control) + '

' + }) + .join('') + return ( + '
' + + fields + + honeypotHtml(config.honeypotField) + + captchaHtml(config.captcha, config.captchaSiteKey) + + '

' + + '
' + + '
' + ) +} + +// ─── Comments ─── + +export interface CommentAuthor { + name: string + url: string | null + isModerator: boolean +} + +export interface PublicComment { + id: string + parentId: string | null + depth: number + author: CommentAuthor + /** Plain text. */ + body: string + type: 'comment' | 'pingback' | 'trackback' + createdAt: string + replies: PublicComment[] +} + +export interface ThreadConfig { + closed: boolean + requireApproval: boolean + requireEmail: boolean + maxDepth: number + maxBodyLength: number + captcha: 'turnstile' | null + captchaSiteKey: string | null + honeypotField: string | null +} + +export interface CommentThread { + entry: { modelId: string, entryId: string, locale: string } + config: ThreadConfig + comments: PublicComment[] + total: number + page: number + limit: number +} + +export interface CommentSubmitBody { + author: { name: string, email?: string, url?: string } + body: string + /** null for a root comment — sent explicitly, as the provider's own fixture does. */ + parentId: string | null + captchaToken?: string + _hp?: string +} + +export interface CommentSubmitResult { + success: boolean + status?: 'pending' | 'approved' + comment?: PublicComment + errors?: FieldError[] +} + +export interface ThreadQuery { + page?: number + limit?: number + sort?: 'oldest' | 'newest' +} + +export function fetchThread(rt: Runtime, entry: EntryRef, query?: ThreadQuery): Promise { + const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], { + locale: entry.locale, + page: query?.page, + limit: query?.limit, + sort: query?.sort, + }) + return getJson(url) +} + +export function submitComment(rt: Runtime, entry: EntryRef, body: CommentSubmitBody): Promise { + const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], { locale: entry.locale }) + return postJson(url, body) +} + +/** Comment-form entries → the documented request body (inputs: author_name, author_email, author_url, body, parent_id). */ +export function commentPayload(entries: Iterable<[string, unknown]>, honeypotField: string | null): CommentSubmitBody { + const values: Record = {} + const payload: CommentSubmitBody = { author: { name: '' }, body: '', parentId: null } + for (const [name, raw] of entries) { + if (typeof raw !== 'string') continue + if (name === CAPTCHA_FIELD) { + if (raw) payload.captchaToken = raw + continue + } + if (honeypotField && name === honeypotField) { + payload._hp = raw + continue + } + values[name] = raw + } + payload.author.name = (values.author_name ?? '').trim() + if (values.author_email?.trim()) payload.author.email = values.author_email.trim() + if (values.author_url?.trim()) payload.author.url = values.author_url.trim() + payload.body = (values.body ?? '').trim() + payload.parentId = values.parent_id?.trim() || null + return payload +} + +/** Plain text → paragraphs; blank lines separate paragraphs, single newlines break lines. */ +export function bodyHtml(text: string): string { + return text + .split(/\n{2,}/) + .map(p => p.trim()) + .filter(Boolean) + .map(p => '

' + esc(p).replace(/\n/g, '
') + '

') + .join('') +} + +export function hasMore(thread: CommentThread): boolean { + return thread.page * thread.limit < thread.total +} + +/** One comment with its replies. A reply button appears only while the thread is open and depth allows it. */ +export function commentHtml(c: PublicComment, config: { closed: boolean, maxDepth: number }): string { + const author = c.author.url + ? '' + esc(c.author.name) + '' + : esc(c.author.name) + const badge = c.author.isModerator ? ' ' + esc(strings.moderator) + '' : '' + const date = '' + const canReply = !config.closed && c.depth < config.maxDepth + const reply = canReply + ? '' + : '' + const replies = c.replies.length + ? '
    ' + c.replies.map(r => commentHtml(r, config)).join('') + '
' + : '' + return ( + '
  • ' + + '
    ' + author + '' + badge + ' ' + date + '
    ' + + '
    ' + bodyHtml(c.body) + '
    ' + + (reply ? '
    ' + reply + '
    ' : '') + + replies + + '
  • ' + ) +} + +export function threadHtml(thread: CommentThread): string { + if (!thread.comments.length) return '

    ' + esc(strings.noComments) + '

    ' + return '
      ' + thread.comments.map(c => commentHtml(c, thread.config)).join('') + '
    ' +} + +/** The comment form; a reply carries its parent in a hidden input. */ +export function commentFormHtml(config: ThreadConfig, prefix = 'cr-c'): string { + const emailLabel = esc(strings.email) + (config.requireEmail ? ' ' : '') + ' ' + esc(strings.emailNote) + '' + return ( + '
    ' + + '' + + '' + + '

    ' + + '

    ' + + '

    ' + + '

    ' + + '

    ' + + '

    ' + + '

    ' + + '

    ' + + honeypotHtml(config.honeypotField) + + captchaHtml(config.captcha, config.captchaSiteKey) + + '

    ' + + '
    ' + + '
    ' + ) +} + +// ─── Turnstile (explicit render, so widgets added after load still render) ─── + +interface TurnstileApi { + render: (el: Element, options: { sitekey: string }) => string + reset: (id?: string) => void +} + +const TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=__crTurnstileReady' +const widgets = new WeakMap() +const waiting: Element[] = [] + +function turnstileApi(): TurnstileApi | undefined { + return (globalThis as { turnstile?: TurnstileApi }).turnstile +} + +function renderWidget(api: TurnstileApi, el: Element): void { + if (widgets.has(el)) return + widgets.set(el, api.render(el, { sitekey: el.getAttribute('data-sitekey') ?? '' })) +} + +/** Render every `.cf-turnstile` under `root`, loading the Turnstile script once on first use. */ +export function renderCaptcha(root: ParentNode): void { + const els = Array.from(root.querySelectorAll('.cf-turnstile')) + if (!els.length) return + const api = turnstileApi() + if (api) { + for (const el of els) renderWidget(api, el) + return + } + waiting.push(...els) + if (document.querySelector('script[data-cr-turnstile]')) return + ;(globalThis as Record).__crTurnstileReady = () => { + const ready = turnstileApi() + if (!ready) return + for (const el of waiting.splice(0)) renderWidget(ready, el) + } + const script = document.createElement('script') + script.src = TURNSTILE_SRC + script.async = true + script.defer = true + script.setAttribute('data-cr-turnstile', '1') + document.head.appendChild(script) +} + +export function resetCaptcha(root: ParentNode): void { + const api = turnstileApi() + if (!api) return + for (const el of Array.from(root.querySelectorAll('.cf-turnstile'))) api.reset(widgets.get(el)) +} + +// ─── Mounting (the only DOM code) ─── + +/** ui-strings dictionary key → the `strings` entry it sets. */ +export const STRING_KEYS: ReadonlyArray = [ + ['common.loading', 'loading'], + ['common.failed', 'failed'], + ['form.send', 'send'], + ['form.sending', 'sending'], + ['form.honeypot', 'honeypot'], + ['comments.title', 'commentsTitle'], + ['comments.empty', 'noComments'], + ['comments.closed', 'closed'], + ['comments.post', 'postComment'], + ['comments.reply', 'reply'], + ['comments.replying_to', 'replyingTo'], + ['comments.cancel', 'cancel'], + ['comments.load_more', 'loadMore'], + ['comments.moderator', 'moderator'], + ['comments.pending', 'pending'], + ['comments.posted', 'posted'], + ['comments.field.name', 'name'], + ['comments.field.email', 'email'], + ['comments.field.email_note', 'emailNote'], + ['comments.field.website', 'website'], + ['comments.field.comment', 'comment'], +] + +/** + * Take the page's interface text from the host element. A value that is not a + * non-empty string leaves the default; text that cannot be parsed changes + * nothing, because a mount that fails over a label is worse than an English one. + */ +export function applyStrings(host: HTMLElement): void { + const raw = host.dataset.strings + if (!raw) return + let text: unknown + try { + text = JSON.parse(raw) + } + catch { + return + } + if (!text || typeof text !== 'object') return + for (const [key, name] of STRING_KEYS) { + const value = (text as Record)[key] + if (typeof value === 'string' && value !== '') strings[name] = value + } +} + +export function runtimeOf(host: HTMLElement): Runtime { + return { base_url: host.dataset.baseUrl ?? '', project_id: host.dataset.project ?? '' } +} + +function entryOf(host: HTMLElement): EntryRef { + const locale = host.dataset.locale + return { model_id: host.dataset.model ?? '', entry_id: host.dataset.entry ?? '', ...(locale ? { locale } : {}) } +} + +function setStatus(form: HTMLFormElement, html: string): void { + const status = form.querySelector('.cr-status') + if (status) status.innerHTML = html +} + +function busy(form: HTMLFormElement, on: boolean): void { + const button = form.querySelector('button[type="submit"]') + if (!button) return + button.disabled = on + if (on) { + button.dataset.label = button.textContent ?? '' + button.textContent = strings.sending + } + else { + button.textContent = button.dataset.label ?? button.textContent + } +} + +function entriesOf(form: HTMLFormElement): Array<[string, unknown]> { + const out: Array<[string, unknown]> = [] + new FormData(form).forEach((value, key) => out.push([key, value])) + return out +} + +/** `` → fetch the config, render, submit. */ +export async function mountForm(host: HTMLElement): Promise { + applyStrings(host) + const rt = runtimeOf(host) + const model = host.dataset.model ?? '' + host.innerHTML = '

    ' + esc(strings.loading) + '

    ' + let config: FormConfig + try { + config = await fetchFormConfig(rt, model) + } + catch (error) { + if (isPaymentRequired(error)) return hideUnavailable(host, 'form') + host.innerHTML = '

    ' + esc(error instanceof Error ? error.message : strings.failed) + '

    ' + return + } + host.innerHTML = formHtml(config) + renderCaptcha(host) + const form = host.querySelector('form') + if (!form) return + form.addEventListener('submit', async (event) => { + event.preventDefault() + busy(form, true) + setStatus(form, '') + try { + const result = await submitForm(rt, model, formPayload(entriesOf(form), config)) + if (result.success) { + host.innerHTML = '

    ' + esc(result.message ?? config.successMessage ?? '') + '

    ' + return + } + setStatus(form, errorsHtml(result.errors ?? [])) + resetCaptcha(form) + } + catch (error) { + if (isPaymentRequired(error)) return hideUnavailable(host, 'form') + setStatus(form, '') + resetCaptcha(form) + } + finally { + busy(form, false) + } + }) +} + +/** `` → thread + form. */ +export async function mountComments(host: HTMLElement): Promise { + applyStrings(host) + const rt = runtimeOf(host) + const entry = entryOf(host) + host.innerHTML = '

    ' + esc(strings.loading) + '

    ' + let thread: CommentThread + try { + thread = await fetchThread(rt, entry) + } + catch (error) { + if (isPaymentRequired(error)) return hideUnavailable(host, 'comments') + host.innerHTML = '

    ' + esc(error instanceof Error ? error.message : strings.failed) + '

    ' + return + } + + host.innerHTML + = '
    ' + + '

    ' + esc(strings.commentsTitle) + ' (' + esc(thread.total) + ')

    ' + + '
    ' + threadHtml(thread) + '
    ' + + (hasMore(thread) ? '

    ' : '') + + (thread.config.closed ? '

    ' + esc(strings.closed) + '

    ' : commentFormHtml(thread.config)) + + '
    ' + renderCaptcha(host) + + const threadEl = host.querySelector('.cr-thread') + const form = host.querySelector('form.cr-comment-form') + let page = thread.page + + host.addEventListener('click', async (event) => { + const target = event.target as HTMLElement | null + if (!target) return + const replyButton = target.closest('.cr-reply') + if (replyButton && form) { + const parent = form.querySelector('input[name="parent_id"]') + if (parent) parent.value = replyButton.dataset.parent ?? '' + const replying = form.querySelector('.cr-replying') + const to = form.querySelector('.cr-replying-to') + if (to) to.textContent = strings.replyingTo + ' ' + (replyButton.dataset.author ?? '') + if (replying) replying.hidden = false + replyButton.closest('.cr-comment')?.appendChild(form) + form.querySelector('textarea[name="body"]')?.focus() + return + } + if (target.closest('.cr-cancel-reply') && form) { + const parent = form.querySelector('input[name="parent_id"]') + if (parent) parent.value = '' + const replying = form.querySelector('.cr-replying') + if (replying) replying.hidden = true + host.querySelector('.cr-comments-section')?.appendChild(form) + return + } + const more = target.closest('.cr-load-more') + if (more && threadEl) { + more.disabled = true + try { + const next = await fetchThread(rt, entry, { page: page + 1 }) + page = next.page + const list = threadEl.querySelector('.cr-comment-list') + if (list) list.insertAdjacentHTML('beforeend', next.comments.map(c => commentHtml(c, next.config)).join('')) + if (!hasMore(next)) more.closest('.cr-more')?.remove() + } + catch (error) { + if (isPaymentRequired(error)) return hideUnavailable(host, 'comments') + // Anything else: the button comes back for another try. + } + finally { + more.disabled = false + } + } + }) + + form?.addEventListener('submit', async (event) => { + event.preventDefault() + busy(form, true) + setStatus(form, '') + try { + const result = await submitComment(rt, entry, commentPayload(entriesOf(form), thread.config.honeypotField)) + if (!result.success) { + setStatus(form, errorsHtml(result.errors ?? [])) + resetCaptcha(form) + return + } + const parentId = result.comment?.parentId ?? null + if (result.status === 'approved' && result.comment && threadEl) { + const html = commentHtml(result.comment, thread.config) + const parent = parentId ? threadEl.querySelector('#cr-comment-' + parentId) : null + if (parent) { + let replies = parent.querySelector(':scope > .cr-replies') + if (!replies) { + parent.insertAdjacentHTML('beforeend', '
      ') + replies = parent.querySelector(':scope > .cr-replies') + } + replies?.insertAdjacentHTML('beforeend', html) + } + else { + const list = threadEl.querySelector('.cr-comment-list') + if (list) list.insertAdjacentHTML('beforeend', html) + else threadEl.innerHTML = '
        ' + html + '
      ' + } + } + form.reset() + host.querySelector('.cr-comments-section')?.appendChild(form) + const replying = form.querySelector('.cr-replying') + if (replying) replying.hidden = true + resetCaptcha(form) + setStatus(form, '

      ' + esc(result.status === 'approved' ? strings.posted : strings.pending) + '

      ') + } + catch (error) { + if (isPaymentRequired(error)) return hideUnavailable(host, 'comments') + setStatus(form, '') + resetCaptcha(form) + } + finally { + busy(form, false) + } + }) +} diff --git a/tests/integration/starter-form-contract.integration.test.ts b/tests/integration/starter-form-contract.integration.test.ts new file mode 100644 index 00000000..512aafe9 --- /dev/null +++ b/tests/integration/starter-form-contract.integration.test.ts @@ -0,0 +1,233 @@ +/** + * Contract between a delivered site and Studio's public forms API: the browser client every migrated + * site ships (`tests/fixtures/starter/embed.ts`, copied from the starter) run against the real `config` + * and `submit` route handlers. Only the database, Git and Turnstile's siteverify are mocked. + * + * What a visitor's form does is the whole chain, so the test walks it: config → the controls the client + * renders (field names, honeypot, Turnstile widget) → the form data a browser would collect from them → + * the request body → Studio's answer → what the client shows for it. + */ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { validateContent } from '../../server/utils/content-validation' +import { + CAPTCHA_FIELD, + EmbedError, + errorsHtml, + fetchFormConfig, + formHtml, + formPayload, + isPaymentRequired, + submitForm, +} from '../fixtures/starter/embed' +import { withTestServer } from '../helpers/http' + +const billingLock = vi.hoisted(() => ({ locked: false })) +vi.mock('../../server/utils/workspace-billing', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveWorkspaceBilling: async (...args: Parameters) => { + if (billingLock.locked && args[2]?.requireAccess) throw Object.assign(new Error('billing.payment_required'), { statusCode: 402, data: { code: 'payment_required', requiresCheckout: true } }) + return actual.resolveWorkspaceBilling(...args) + }, + } +}) + +const PROJECT = 'project-1' +const WORKSPACE = 'workspace-1' +const SITE_KEY = '0xSITE' + +const dictionary = JSON.parse(readFileSync(new URL('../../.contentrain/content/system/error-messages/en.json', import.meta.url), 'utf8')) as Record +const realErrorMessage = (key: string) => dictionary[key] ?? key + +const contactModel = { + id: 'contact', + name: 'Contact', + kind: 'collection', + fields: { + name: { type: 'string', label: 'Name', required: true }, + email: { type: 'email', label: 'Email', required: true }, + message: { type: 'text', label: 'Message', required: false }, + internal_note: { type: 'string', label: 'Internal note' }, + }, + form: { + enabled: true, + public: true, + exposedFields: ['name', 'email', 'message'], + honeypot: true, + captcha: 'turnstile', + successMessage: 'Thanks! We will get back to you.', + }, +} + +function stubStudio() { + vi.stubGlobal('getRouterParam', vi.fn((_: unknown, key: string) => key === 'projectId' ? PROJECT : key === 'modelId' ? 'contact' : undefined)) + vi.stubGlobal('errorMessage', realErrorMessage) + vi.stubGlobal('getWorkspacePlan', vi.fn().mockReturnValue('pro')) + vi.stubGlobal('hasFeature', vi.fn().mockReturnValue(true)) + vi.stubGlobal('getPlanLimit', vi.fn().mockReturnValue(Number.MAX_SAFE_INTEGER)) + vi.stubGlobal('useGitProvider', vi.fn().mockReturnValue({})) + vi.stubGlobal('normalizeContentRoot', vi.fn().mockReturnValue('.contentrain')) + vi.stubGlobal('getOrBuildBrainCache', vi.fn().mockResolvedValue({ + config: { locales: { default: 'en', supported: ['en'] } }, + models: new Map([['contact', contactModel]]), + })) + vi.stubGlobal('validateContent', validateContent) + vi.stubGlobal('emitWebhookEvent', vi.fn().mockResolvedValue(undefined)) + vi.stubGlobal('recordFormSubmissionUsage', vi.fn().mockResolvedValue(undefined)) + vi.stubGlobal('useEmailProvider', vi.fn().mockReturnValue(null)) + vi.stubGlobal('useRuntimeConfig', () => ({ + public: { siteUrl: 'https://studio.test', turnstileSiteKey: SITE_KEY }, + turnstile: { secretKey: 'turnstile-secret' }, + })) + const createFormSubmissionIfAllowed = vi.fn().mockResolvedValue({ allowed: true, currentCount: 1, submission: { id: 'sub-1', status: 'pending' } }) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + getProjectById: vi.fn().mockResolvedValue({ id: PROJECT, workspace_id: WORKSPACE, repo_full_name: 'acme/site', content_root: '.contentrain' }), + getWorkspaceById: vi.fn().mockResolvedValue({ id: WORKSPACE, name: 'Acme', slug: 'acme', plan: 'pro', github_installation_id: 42, overage_settings: null }), + createFormSubmissionIfAllowed, + })) + return { createFormSubmissionIfAllowed } +} + +/** Turnstile's siteverify is the only outbound call; the starter client's own requests reach the test server. */ +function stubTurnstile(valid: boolean) { + const realFetch = globalThis.fetch + const siteverify = vi.fn(async () => new Response(JSON.stringify({ success: valid }), { headers: { 'content-type': 'application/json' } })) + vi.stubGlobal('fetch', (input: RequestInfo | URL, init?: RequestInit) => + String(input).startsWith('https://challenges.cloudflare.com/') ? siteverify() : realFetch(input, init)) + return siteverify +} + +async function routes() { + const config = (await import('../../server/api/forms/v1/[projectId]/[modelId]/config.get')).default + const submit = (await import('../../server/api/forms/v1/[projectId]/[modelId]/submit.post')).default + return [ + { path: `/api/forms/v1/${PROJECT}/contact/config`, handler: config }, + { path: `/api/forms/v1/${PROJECT}/contact/submit`, handler: submit }, + ] +} + +/** The names a browser would collect from the markup the client rendered — what `new FormData(form)` iterates. */ +function controlNames(html: string): string[] { + return [...html.matchAll(/<(?:input|textarea|select)\b[^>]*\bname="([^"]+)"/g)].map(m => m[1]!) +} + +/** What the browser holds once the visitor typed: every named control, plus the token Turnstile's widget writes. */ +function formData(names: string[], typed: Record, token?: string): Array<[string, unknown]> { + const entries: Array<[string, unknown]> = names.map(name => [name, typed[name] ?? '']) + if (token !== undefined) entries.push([CAPTCHA_FIELD, token]) + return entries +} + +describe('delivered site ↔ Studio forms API', () => { + it('the form the client renders from Studio\'s config posts a body Studio accepts, and stores only exposed fields', async () => { + const { createFormSubmissionIfAllowed } = stubStudio() + const siteverify = stubTurnstile(true) + + await withTestServer({ routes: await routes() }, async ({ baseUrl }) => { + const rt = { base_url: baseUrl, project_id: PROJECT } + const config = await fetchFormConfig(rt, 'contact') + expect(config.captcha).toBe('turnstile') + expect(config.captchaSiteKey).toBe(SITE_KEY) + expect(config.honeypotField).toBe('_hp') + + const html = formHtml(config) + expect(html).toContain(`data-sitekey="${SITE_KEY}"`) + const names = controlNames(html) + expect(names).toEqual(['name', 'email', 'message', '_hp']) + expect(names).not.toContain('internal_note') + + const payload = formPayload(formData(names, { name: 'Ada', email: 'ada@example.com', message: 'Hello' }, 'turnstile-token'), config) + expect(payload).toEqual({ data: { name: 'Ada', email: 'ada@example.com', message: 'Hello' }, captchaToken: 'turnstile-token', _hp: '' }) + + const result = await submitForm(rt, 'contact', payload) + expect(result).toEqual({ success: true, message: 'Thanks! We will get back to you.' }) + expect(siteverify).toHaveBeenCalledTimes(1) + expect(createFormSubmissionIfAllowed).toHaveBeenCalledWith(WORKSPACE, expect.anything(), expect.objectContaining({ + model_id: 'contact', + data: { name: 'Ada', email: 'ada@example.com', message: 'Hello' }, + })) + }) + }) + + it('a filled honeypot reads as success to the client and writes nothing', async () => { + const { createFormSubmissionIfAllowed } = stubStudio() + stubTurnstile(true) + + await withTestServer({ routes: await routes() }, async ({ baseUrl }) => { + const rt = { base_url: baseUrl, project_id: PROJECT } + const config = await fetchFormConfig(rt, 'contact') + const names = controlNames(formHtml(config)) + const payload = formPayload(formData(names, { name: 'Bot', email: 'bot@example.com', _hp: 'spam' }, 'turnstile-token'), config) + expect(payload._hp).toBe('spam') + + expect(await submitForm(rt, 'contact', payload)).toMatchObject({ success: true }) + expect(createFormSubmissionIfAllowed).not.toHaveBeenCalled() + }) + }) + + it('no Turnstile token, or a rejected one, comes back as a captcha error the client lists', async () => { + const { createFormSubmissionIfAllowed } = stubStudio() + const siteverify = stubTurnstile(false) + + await withTestServer({ routes: await routes() }, async ({ baseUrl }) => { + const rt = { base_url: baseUrl, project_id: PROJECT } + const config = await fetchFormConfig(rt, 'contact') + const names = controlNames(formHtml(config)) + const typed = { name: 'Ada', email: 'ada@example.com' } + + // The widget has not produced a token yet: the client sends none (an empty value is dropped, not posted). + const missing = formPayload(formData(names, typed, ''), config) + expect(missing.captchaToken).toBeUndefined() + const noToken = await submitForm(rt, 'contact', missing) + expect(noToken.success).toBe(false) + expect(noToken.errors).toEqual([{ field: 'captcha', message: dictionary['forms.captcha_failed'] }]) + expect(siteverify).not.toHaveBeenCalled() + + const rejected = await submitForm(rt, 'contact', formPayload(formData(names, typed, 'stale-token'), config)) + expect(rejected.success).toBe(false) + expect(rejected.errors?.[0]?.field).toBe('captcha') + expect(siteverify).toHaveBeenCalledTimes(1) + expect(errorsHtml(rejected.errors ?? [])).toContain('data-field="captcha"') + expect(createFormSubmissionIfAllowed).not.toHaveBeenCalled() + }) + }) + + it('a required field left empty is answered as `success: false` with `{ field, message }` entries', async () => { + const { createFormSubmissionIfAllowed } = stubStudio() + stubTurnstile(true) + + await withTestServer({ routes: await routes() }, async ({ baseUrl }) => { + const rt = { base_url: baseUrl, project_id: PROJECT } + const config = await fetchFormConfig(rt, 'contact') + const names = controlNames(formHtml(config)) + const result = await submitForm(rt, 'contact', formPayload(formData(names, { email: 'not-an-email' }, 'turnstile-token'), config)) + + expect(result.success).toBe(false) + expect(result.errors?.map(e => e.field)).toContain('name') + expect(typeof result.errors?.[0]?.message).toBe('string') + expect(createFormSubmissionIfAllowed).not.toHaveBeenCalled() + }) + }) + + it('a locked workspace answers 402 on config and submit, which the client hides the widget for', async () => { + stubStudio() + stubTurnstile(true) + billingLock.locked = true + try { + await withTestServer({ routes: await routes() }, async ({ baseUrl }) => { + const rt = { base_url: baseUrl, project_id: PROJECT } + const onConfig = await fetchFormConfig(rt, 'contact').catch((e: unknown) => e) + expect(onConfig).toBeInstanceOf(EmbedError) + expect(isPaymentRequired(onConfig)).toBe(true) + + const onSubmit = await submitForm(rt, 'contact', { data: { name: 'Ada', email: 'ada@example.com' } }).catch((e: unknown) => e) + expect(isPaymentRequired(onSubmit)).toBe(true) + }) + } + finally { + billingLock.locked = false + } + }) +}) From 18159188698458d39081a37a6db4a094ce62e646 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:29:38 +0300 Subject: [PATCH 2/2] test(forms): keep the starter fixture byte-identical to the starter --- eslint.config.mjs | 2 + tests/fixtures/starter/embed.ts | 156 +++++++++++++++----------------- 2 files changed, 74 insertions(+), 84 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 664ea9c2..6366b59e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,6 +2,8 @@ import withNuxt from './.nuxt/eslint.config.mjs' export default withNuxt( + // Copied byte for byte from the starter (see tests/fixtures/starter/README.md): reformatting it would hide drift. + { ignores: ['tests/fixtures/starter/**'] }, { rules: { 'no-console': 'warn', diff --git a/tests/fixtures/starter/embed.ts b/tests/fixtures/starter/embed.ts index 3f218427..a633fd5e 100644 --- a/tests/fixtures/starter/embed.ts +++ b/tests/fixtures/starter/embed.ts @@ -96,7 +96,7 @@ export const esc = (value: unknown): string => /** `base` without a trailing slash, segments URL-encoded, empty query values dropped. */ export function publicUrl(base: string, segments: string[], query?: Record): string { - const path = segments.map(s => encodeURIComponent(s)).join('/') + const path = segments.map((s) => encodeURIComponent(s)).join('/') const params = new URLSearchParams() for (const [key, value] of Object.entries(query ?? {})) { if (value !== undefined && value !== '') params.set(key, String(value)) @@ -110,12 +110,11 @@ async function failure(res: Response): Promise { let message = text || 'Request failed' let code: string | undefined try { - const parsed = JSON.parse(text) as { message?: unknown, statusMessage?: unknown, data?: { code?: unknown } } + const parsed = JSON.parse(text) as { message?: unknown; statusMessage?: unknown; data?: { code?: unknown } } const m = parsed.message ?? parsed.statusMessage if (typeof m === 'string' && m) message = m if (typeof parsed.data?.code === 'string') code = parsed.data.code - } - catch { + } catch { // not JSON — the raw text is the message } return new EmbedError(res.status, message, code) @@ -216,7 +215,7 @@ function coerce(def: FieldDef, value: string): unknown { */ export function formPayload( entries: Iterable<[string, unknown]>, - config: { fields: Record, honeypotField: string | null }, + config: { fields: Record; honeypotField: string | null }, ): FormPayload { const payload: FormPayload = { data: {} } for (const [name, raw] of entries) { @@ -244,13 +243,13 @@ export function labelFor(id: string, def: FieldDef): string { /** One input for one exposed field, chosen by field type. */ export function fieldControl(id: string, def: FieldDef, prefix = 'cr-field'): string { - const common - = ' id="' + esc(prefix + '-' + id) + '" name="' + esc(id) + '"' - + (def.required ? ' required' : '') - + (def.pattern ? ' pattern="' + esc(def.pattern) + '"' : '') - const range - = (def.min !== undefined ? ' min="' + esc(def.min) + '"' : '') - + (def.max !== undefined ? ' max="' + esc(def.max) + '"' : '') + const common = + ' id="' + esc(prefix + '-' + id) + '" name="' + esc(id) + '"' + + (def.required ? ' required' : '') + + (def.pattern ? ' pattern="' + esc(def.pattern) + '"' : '') + const range = + (def.min !== undefined ? ' min="' + esc(def.min) + '"' : '') + + (def.max !== undefined ? ' max="' + esc(def.max) + '"' : '') const length = def.max !== undefined ? ' maxlength="' + esc(def.max) + '"' : '' switch (def.type) { case 'text': @@ -260,7 +259,7 @@ export function fieldControl(id: string, def: FieldDef, prefix = 'cr-field'): st return '' case 'select': { const blank = def.required ? '' : '' - const options = (def.options ?? []).map(o => '').join('') + const options = (def.options ?? []).map((o) => '').join('') return '' + blank + options + '' } case 'boolean': @@ -292,8 +291,8 @@ export function fieldControl(id: string, def: FieldDef, prefix = 'cr-field'): st export function honeypotHtml(field: string | null): string { if (!field) return '' return ( - '' + '' ) } @@ -304,9 +303,9 @@ export function captchaHtml(captcha: 'turnstile' | null, siteKey: string | null) export function errorsHtml(errors: FieldError[]): string { return ( - '' + '' ) } @@ -320,13 +319,13 @@ export function formHtml(config: FormConfig, prefix = 'cr-field'): string { }) .join('') return ( - '
      ' - + fields - + honeypotHtml(config.honeypotField) - + captchaHtml(config.captcha, config.captchaSiteKey) - + '

      ' - + '
      ' - + '
      ' + '
      ' + + fields + + honeypotHtml(config.honeypotField) + + captchaHtml(config.captcha, config.captchaSiteKey) + + '

      ' + + '
      ' + + '
      ' ) } @@ -362,7 +361,7 @@ export interface ThreadConfig { } export interface CommentThread { - entry: { modelId: string, entryId: string, locale: string } + entry: { modelId: string; entryId: string; locale: string } config: ThreadConfig comments: PublicComment[] total: number @@ -371,7 +370,7 @@ export interface CommentThread { } export interface CommentSubmitBody { - author: { name: string, email?: string, url?: string } + author: { name: string; email?: string; url?: string } body: string /** null for a root comment — sent explicitly, as the provider's own fixture does. */ parentId: string | null @@ -435,9 +434,9 @@ export function commentPayload(entries: Iterable<[string, unknown]>, honeypotFie export function bodyHtml(text: string): string { return text .split(/\n{2,}/) - .map(p => p.trim()) + .map((p) => p.trim()) .filter(Boolean) - .map(p => '

      ' + esc(p).replace(/\n/g, '
      ') + '

      ') + .map((p) => '

      ' + esc(p).replace(/\n/g, '
      ') + '

      ') .join('') } @@ -446,7 +445,7 @@ export function hasMore(thread: CommentThread): boolean { } /** One comment with its replies. A reply button appears only while the thread is open and depth allows it. */ -export function commentHtml(c: PublicComment, config: { closed: boolean, maxDepth: number }): string { +export function commentHtml(c: PublicComment, config: { closed: boolean; maxDepth: number }): string { const author = c.author.url ? '' + esc(c.author.name) + '' : esc(c.author.name) @@ -457,43 +456,43 @@ export function commentHtml(c: PublicComment, config: { closed: boolean, maxDept ? '' : '' const replies = c.replies.length - ? '
        ' + c.replies.map(r => commentHtml(r, config)).join('') + '
      ' + ? '
        ' + c.replies.map((r) => commentHtml(r, config)).join('') + '
      ' : '' return ( - '
    1. ' - + '
      ' + author + '' + badge + ' ' + date + '
      ' - + '
      ' + bodyHtml(c.body) + '
      ' - + (reply ? '
      ' + reply + '
      ' : '') - + replies - + '
    2. ' + '
    3. ' + + '
      ' + author + '' + badge + ' ' + date + '
      ' + + '
      ' + bodyHtml(c.body) + '
      ' + + (reply ? '
      ' + reply + '
      ' : '') + + replies + + '
    4. ' ) } export function threadHtml(thread: CommentThread): string { if (!thread.comments.length) return '

      ' + esc(strings.noComments) + '

      ' - return '
        ' + thread.comments.map(c => commentHtml(c, thread.config)).join('') + '
      ' + return '
        ' + thread.comments.map((c) => commentHtml(c, thread.config)).join('') + '
      ' } /** The comment form; a reply carries its parent in a hidden input. */ export function commentFormHtml(config: ThreadConfig, prefix = 'cr-c'): string { const emailLabel = esc(strings.email) + (config.requireEmail ? ' ' : '') + ' ' + esc(strings.emailNote) + '' return ( - '
      ' - + '' - + '' - + '

      ' - + '

      ' - + '

      ' - + '

      ' - + '

      ' - + '

      ' - + '

      ' - + '

      ' - + honeypotHtml(config.honeypotField) - + captchaHtml(config.captcha, config.captchaSiteKey) - + '

      ' - + '
      ' - + '
      ' + '
      ' + + '' + + '' + + '

      ' + + '

      ' + + '

      ' + + '

      ' + + '

      ' + + '

      ' + + '

      ' + + '

      ' + + honeypotHtml(config.honeypotField) + + captchaHtml(config.captcha, config.captchaSiteKey) + + '

      ' + + '
      ' + + '
      ' ) } @@ -585,8 +584,7 @@ export function applyStrings(host: HTMLElement): void { let text: unknown try { text = JSON.parse(raw) - } - catch { + } catch { return } if (!text || typeof text !== 'object') return @@ -617,8 +615,7 @@ function busy(form: HTMLFormElement, on: boolean): void { if (on) { button.dataset.label = button.textContent ?? '' button.textContent = strings.sending - } - else { + } else { button.textContent = button.dataset.label ?? button.textContent } } @@ -638,8 +635,7 @@ export async function mountForm(host: HTMLElement): Promise { let config: FormConfig try { config = await fetchFormConfig(rt, model) - } - catch (error) { + } catch (error) { if (isPaymentRequired(error)) return hideUnavailable(host, 'form') host.innerHTML = '

      ' + esc(error instanceof Error ? error.message : strings.failed) + '

      ' return @@ -660,13 +656,11 @@ export async function mountForm(host: HTMLElement): Promise { } setStatus(form, errorsHtml(result.errors ?? [])) resetCaptcha(form) - } - catch (error) { + } catch (error) { if (isPaymentRequired(error)) return hideUnavailable(host, 'form') setStatus(form, '') resetCaptcha(form) - } - finally { + } finally { busy(form, false) } }) @@ -681,20 +675,19 @@ export async function mountComments(host: HTMLElement): Promise { let thread: CommentThread try { thread = await fetchThread(rt, entry) - } - catch (error) { + } catch (error) { if (isPaymentRequired(error)) return hideUnavailable(host, 'comments') host.innerHTML = '

      ' + esc(error instanceof Error ? error.message : strings.failed) + '

      ' return } - host.innerHTML - = '
      ' - + '

      ' + esc(strings.commentsTitle) + ' (' + esc(thread.total) + ')

      ' - + '
      ' + threadHtml(thread) + '
      ' - + (hasMore(thread) ? '

      ' : '') - + (thread.config.closed ? '

      ' + esc(strings.closed) + '

      ' : commentFormHtml(thread.config)) - + '
      ' + host.innerHTML = + '
      ' + + '

      ' + esc(strings.commentsTitle) + ' (' + esc(thread.total) + ')

      ' + + '
      ' + threadHtml(thread) + '
      ' + + (hasMore(thread) ? '

      ' : '') + + (thread.config.closed ? '

      ' + esc(strings.closed) + '

      ' : commentFormHtml(thread.config)) + + '
      ' renderCaptcha(host) const threadEl = host.querySelector('.cr-thread') @@ -731,14 +724,12 @@ export async function mountComments(host: HTMLElement): Promise { const next = await fetchThread(rt, entry, { page: page + 1 }) page = next.page const list = threadEl.querySelector('.cr-comment-list') - if (list) list.insertAdjacentHTML('beforeend', next.comments.map(c => commentHtml(c, next.config)).join('')) + if (list) list.insertAdjacentHTML('beforeend', next.comments.map((c) => commentHtml(c, next.config)).join('')) if (!hasMore(next)) more.closest('.cr-more')?.remove() - } - catch (error) { + } catch (error) { if (isPaymentRequired(error)) return hideUnavailable(host, 'comments') // Anything else: the button comes back for another try. - } - finally { + } finally { more.disabled = false } } @@ -766,8 +757,7 @@ export async function mountComments(host: HTMLElement): Promise { replies = parent.querySelector(':scope > .cr-replies') } replies?.insertAdjacentHTML('beforeend', html) - } - else { + } else { const list = threadEl.querySelector('.cr-comment-list') if (list) list.insertAdjacentHTML('beforeend', html) else threadEl.innerHTML = '
        ' + html + '
      ' @@ -779,13 +769,11 @@ export async function mountComments(host: HTMLElement): Promise { if (replying) replying.hidden = true resetCaptcha(form) setStatus(form, '

      ' + esc(result.status === 'approved' ? strings.posted : strings.pending) + '

      ') - } - catch (error) { + } catch (error) { if (isPaymentRequired(error)) return hideUnavailable(host, 'comments') setStatus(form, '') resetCaptcha(form) - } - finally { + } finally { busy(form, false) } })