stack/apps/e2e/tests/helpers.ts
BilalG1 c14a9dd3d0
feat(hexclave): PR 5 — internal symbol/path/package renames + brand strings (#1547)
## Stack Auth → Hexclave rename — PR 5 (internal symbols, paths,
packages, brand strings)

PR 5 finishes the **internal / non-wire** half of the Stack→Hexclave
rename. It only touches things where nothing outside the repo depends on
the exact name: internal symbols, file/dir names, the
`@stackframe/template` package, and residual brand strings. Plan +
progress are in `HEXCLAVE-RENAME-PR5-PLAN.md`.

Every step was verified green (`pnpm typecheck` + `pnpm lint`, 28/28)
and committed as its own checkpoint, then a fan-out of review agents
audited all commits and the findings were fixed.

### What changed
- **Internal symbols** (`@hexclave/shared`, `packages/template`, apps):
`stack*`/`Stack*` → `hexclave*`/`Hexclave*` — incl.
`stackGlobalsSymbol`, the `_Stack*AppImpl` classes,
`stackAppInternalsSymbol`, `StackContext`, `getStackStripe`, etc. The
`stack*App` local-variable convention
(`stackServerApp`/`stackClientApp`/…) was renamed across 175
source/example/doc files.
- **File renames**: `hexclave-handler/provider/context.tsx`,
`backend/hexclave.tsx`, `internal-tool/hexclave.ts`,
`hexclave-app-internals.ts`.
- **Directory renames**: `lib/hexclave-app`, `hexclave-companion`,
`[...hexclave]` route segment, `skills/hexclave`,
`dashboard/src/hexclave`, and the package dirs
**`packages/{next,shared,ui,sc,cli}`** (dropping the `stack-` prefix to
match the `@hexclave/*` npm names).
- **Packages**: `@stackframe/template` → `@hexclave/template`; **deleted
`packages/init-stack`** (onboarding lives in `@hexclave/cli init`; the
published npm package is untouched).
- **Brand strings**: reworded `Stack Auth`/`Stack dashboard` prose in
code + docs-mintlify, renamed `hexclave-app.mdx`/`use-hexclave-app.mdx`
with redirects, regenerated OpenAPI, updated coupled e2e assertions;
`doctor`/`init` now prefer `hexclave.config.ts`.

### Intentionally kept (verified, not oversights)
Wire/compat identifiers (`x-stack-*` headers, `stack-*` cookies,
`STACK_*` env names, `*.stack-auth.com`, `stackauth_`, `ask_stack_auth`,
query params), public `Stack*` SDK aliases, crypto/JWT/vault
domain-separation tags, `*-brand-sentinel`s, the
`Symbol.for("StackAuth--…")` string, `_stack_sync_metadata`, Postgres
`stackframe` / docker image names, the `stack-auth-logo*.svg` (used by
the rebrand modal), and `migration.mdx` / "formerly known as Stack Auth"
notes. False positives (Phosphor `StackIcon`/`StackSimple`, `TanStack`,
`OrbStack`, `stackable`/`Stacked` charts) left alone.

### Review pass
Six review agents audited all commits. Found + fixed one real bug — a
build script (`bundle-type-definitions.ts`) hardcoded the old
`lib/stack-app` glob path (not an import, so typecheck/lint were blind),
silently emptying the dashboard AI type bundle — plus stale comments, a
dead CI env var, and stale `.gitignore`/`.dockerignore` entries.
Cross-cutting audit confirmed **zero wire-compat identifiers were
accidentally renamed**.

### ⚠️ Verification note
`typecheck` + `lint` are fully green locally. The **e2e suite was not
run** (needs a live backend+DB), so the brand-string assertion +
OpenAPI-regen changes are verified by grep/codegen only — please let CI
exercise e2e to confirm.

### Base-branch note
This branch was forked from the local-only `cl/friendly-lewin-72293f`
(not on origin, no separate PR), so this PR against `dev` also carries
that branch's ~11 preceding Hexclave-rename commits (config-file rename,
env-var dual-read, AI setup-prompt rebrand). If those should land
separately, re-parent before merge.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Finishes the internal Stack Auth → Hexclave rename and cleans up
remaining stragglers, including dev-tool and prompt copy. All changes
are internal-only; public/wire APIs remain unchanged. Re-merged `dev`
and resolved the payments create-purchase-url conflict.

- **Refactors**
- Internal symbols: stack*/Stack* → hexclave*/Hexclave* (e.g.,
`getHexclaveServerApp` via `@/hexclave`, `getHexclaveStripe`,
`hexclaveAppInternalsSymbol`, `hexclaveSchemaInfo`, Prisma
`__hexclave_*`, `data-hexclave-handler-page`, Stripe mock
`hexclavePortPrefix`).
- Files/dirs: moved to `lib/hexclave-app`; handler route
`[...hexclave]`; backend entry `src/hexclave.tsx`; dashboard internals
`hexclave-app-internals`; companion `hexclave-companion`; dropped
`stack-` prefix across package dirs
(`packages/{shared,ui,sc,cli,next}`); workflows/emulator paths now
`packages/cli`; Quetzal codegen env at `packages/next/.env.local`.
- Packages/docs: `@stackframe/template` → `@hexclave/template`; removed
`packages/init-stack`; regenerated OpenAPI and updated docs
slugs/redirects for hexclave-app/use-hexclave-app.
- Brand strings/prompts: reworded remaining “Stack” dashboard strings to
Hexclave; updated dev-tool copy and prompts; `doctor/init` now prefer
`hexclave.config.ts`. Kept all wire-compat identifiers and public
aliases (`x-stack-*`, `stack-*` cookies, `STACK_*` env,
`*.stack-auth.com`, `Stack*` SDK names).
- Rebased/merged onto latest `dev`: retained `@hexclave/template`, kept
`src` in published files, refreshed setup-prompt imports and docs JSON,
adopted 1.0.5 version bumps, and re-merged `dev` again (resolved
`create-purchase-url` with `getHexclaveStripe`).

- **Bug Fixes**
- Restored dashboard AI type bundle by pointing the glob to
`packages/template/src/lib/hexclave-app`.
- Addressed rename leftovers: updated lingering `@/stack` imports and
CSS selector, fixed schema/meta and port-prefix expansions, and aligned
emulator commands to `packages/cli`.
- CI/build: removed a dead env var and stale ignore entries; fixed
Docker by renaming `STACK_SKIP_TEMPLATE_GENERATION` →
`HEXCLAVE_SKIP_TEMPLATE_GENERATION`.

<sup>Written for commit 3c1af3bff3.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1547?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-06-03 18:57:09 -07:00

336 lines
13 KiB
TypeScript

import { generateSecureRandomString } from "@hexclave/shared/dist/utils/crypto";
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors";
import { filterUndefined, omit } from "@hexclave/shared/dist/utils/objects";
import { ignoreUnhandledRejection, wait } from "@hexclave/shared/dist/utils/promises";
import { Nicifiable } from "@hexclave/shared/dist/utils/strings";
import { AsyncLocalStorage } from "node:async_hooks";
// eslint-disable-next-line no-restricted-imports
import { afterEach, beforeEach, onTestFailed, test as vitestTest } from "vitest";
export const test: typeof vitestTest = vitestTest.extend({});
export const it: typeof vitestTest = test;
export const afterTestFinishesCallbacks: (() => Promise<void>)[] = [];
export function logIfTestFails(...args: any[]) {
onTestFailed(() => {
console.error(...args);
});
}
export function runAsynchronouslyBeforeTestFinishes(callback: () => Promise<void>) {
const promise = callback();
ignoreUnhandledRejection(promise);
afterTestFinishesCallbacks.push(async () => await promise);
}
export function beforeTestFinishes(callback: () => Promise<void>) {
afterTestFinishesCallbacks.push(callback);
}
export class Context<R, T> {
// we want to retain order in which the values were set instead of the order in which the beforeEach callback was called, so we keep a Map and a Set together here
private _values = new Map<string, T>();
private _yetToReduce = new Set<string>();
private _deleteOnFinish = new Set<string>();
private _reduced: R | undefined;
private _withStorage: AsyncLocalStorage<T[]> = new AsyncLocalStorage();
private _isInTest = false;
constructor(private readonly _getInitialValue: () => R, private readonly _reducer: (acc: R, value: T) => R) {
beforeEach(async () => {
if (this._isInTest) {
throw new HexclaveAssertionError("beforeEach was called twice without a single afterEach! Are you running tests concurrently? This is not supported by withContext.");
}
if (this._withStorage.getStore()) {
throw new HexclaveAssertionError("Did you wrap an entire test into Context.with(...)?");
}
this._reduced = this._getInitialValue();
this._isInTest = true;
if (this._yetToReduce.size > 0) {
throw new HexclaveAssertionError("Something went wrong; _yetToReduce should be empty here.");
}
});
afterEach(async () => {
if (this._withStorage.getStore()) {
throw new HexclaveAssertionError("Test finished before _withStorage was cleaned up! This should not happen.");
}
this._isInTest = false;
this._reduced = undefined;
for (const key of this._deleteOnFinish) {
this._yetToReduce.delete(key);
}
if (this._yetToReduce.size > 0) {
throw new HexclaveAssertionError("Something went wrong; _yetToReduce should be empty here.");
}
});
}
async with<X>(value: T, callback: () => Promise<X>) {
const oldWithStorage = this._withStorage.getStore() ?? [];
return await this._withStorage.run([...oldWithStorage, value], async () => {
return await callback();
});
}
set(value: T) {
const randomId = generateSecureRandomString();
this._values.set(randomId, value);
const before = () => {
if (this._yetToReduce.has(randomId)) {
throw new HexclaveAssertionError("Value setter was called twice without a single afterEach! Are you running tests concurrently? This is not supported by withContext.");
}
this._yetToReduce.add(randomId);
};
if (this._isInTest) {
before();
this._deleteOnFinish.add(randomId);
} else {
beforeEach(async () => {
before();
});
afterEach(() => {
this._yetToReduce.delete(randomId);
});
}
}
get value(): R {
this._reduce();
const _withStore = this._withStorage.getStore() ?? [];
return _withStore.reduce((acc, val) => this._reducer(acc, val), this._reduced as R);
}
private _reduce() {
if (!this._isInTest) {
throw new HexclaveAssertionError("You can only call this function on Context inside a test.");
}
const yetToReduceOrdered = [...this._values.entries()].filter(([key]) => this._yetToReduce.has(key)).map(([, value]) => value);
for (const value of yetToReduceOrdered) {
this._reduced = this._reducer(this._reduced as R, value);
}
this._yetToReduce = new Set();
}
}
export function updateCookie(cookieString: string, cookieName: string, cookieValue: string) {
const cookies = cookieString.split(";").map((cookie) => cookie.trim()).filter((cookie) => cookie.length > 0);
const newCookie = `${cookieName}=${cookieValue}`;
const cookieIndex = cookies.findIndex((cookie) => cookie.startsWith(`${cookieName}=`));
if (cookieIndex === -1) {
return `${cookieString}; ${newCookie}`;
}
cookies[cookieIndex] = newCookie;
return cookies.join("; ");
}
export function updateCookiesFromResponse(cookieString: string, update: NiceResponse) {
const setCookies = update.headers.getSetCookie();
for (const setCookie of setCookies) {
const [cookieName, cookieValue] = setCookie.split(";")[0].split("=");
cookieString = updateCookie(cookieString, cookieName, cookieValue);
}
return cookieString;
}
export class NiceResponse implements Nicifiable {
constructor(
public readonly status: number,
public readonly headers: Headers,
public readonly body: any,
public readonly fromRequestInit?: NiceRequestInit,
) { }
getNicifiableKeys(): string[] {
// reorder the keys for nicer printing
return [
"status",
...this.body instanceof ArrayBuffer && this.body.byteLength === 0 ? [] : ["body"],
"headers",
];
}
async follow(options?: NiceRequestInit) {
if (![301, 302, 303, 307, 308].includes(this.status)) {
throw new HexclaveAssertionError(`Cannot follow non-redirect response: ${this.status}`);
}
const location = this.headers.get("Location");
if (!location) {
throw new HexclaveAssertionError(`Redirect response has no Location header: ${this.status}`);
}
const followRes = await niceFetch(location, {
...[301, 302, 303].includes(this.status) ? { method: "GET" } : {
body: this.fromRequestInit?.body,
method: this.fromRequestInit?.method,
headers: this.fromRequestInit?.headers,
},
...options,
});
return followRes;
}
};
export type NiceRequestInit = RequestInit & {
query?: Record<string, string>,
};
export async function niceFetch(url: string | URL, options?: NiceRequestInit): Promise<NiceResponse> {
if (options?.query) {
url = new URL(url);
for (const [key, value] of Object.entries(options.query)) {
url.searchParams.append(key, value);
}
}
const fetchRes = await fetch(url, {
...options,
headers: {
"x-stack-disable-artificial-development-delay": "yes",
"x-stack-development-disable-extended-logging": "yes",
...filterUndefined(options?.headers ?? {}),
},
});
let body;
if (fetchRes.headers.get("content-type")?.includes("application/json")) {
body = await fetchRes.json();
} else if (fetchRes.headers.get("content-type")?.startsWith("text/")) {
body = await fetchRes.text();
} else {
body = await fetchRes.arrayBuffer();
}
return new NiceResponse(fetchRes.status, fetchRes.headers, body, options);
}
export const localRedirectUrl = "http://stack-test.localhost/some-callback-url";
export const localRedirectUrlRegex = /http:\/\/stack-test\.localhost\/some-callback-url([?#][A-Za-z0-9\-._~:\/?#\[\]@!$&\'()*+,;=]*)?/g;
export const generatedEmailSuffix = "@stack-generated.example.com";
export const generatedEmailRegex = /[a-zA-Z0-9_.+\-]+@stack-generated\.example\.com/;
export class Mailbox {
public readonly fetchMessages: (options?: { noBody?: boolean }) => Promise<MailboxMessage[]>;
public readonly waitForMessagesWithSubject: (subject: string, options?: { noBody?: boolean }) => Promise<MailboxMessage[]>;
public readonly waitForMessagesWithSubjectCount: (subject: string, minCount: number, options?: { noBody?: boolean }) => Promise<MailboxMessage[]>;
constructor(
disclaimer: "USE_CREATE_MAILBOX_FUNCTION_INSTEAD",
public readonly emailAddress: string,
) {
const mailboxName = emailAddress.split("@")[0];
const fullMessageCache = new Map<string, any>();
this.fetchMessages = async ({ noBody } = {}) => {
const res = await niceFetch(new URL(`/api/v1/mailbox/${encodeURIComponent(mailboxName)}`, STACK_INBUCKET_API_URL));
return await Promise.all((res.body as any[]).map(async (message) => {
let fullMessage: any;
if (fullMessageCache.has(message.id)) {
fullMessage = fullMessageCache.get(message.id);
} else {
const fullMessageRes = await niceFetch(new URL(`/api/v1/mailbox/${encodeURIComponent(mailboxName)}/${message.id}`, STACK_INBUCKET_API_URL));
fullMessage = fullMessageRes.body;
fullMessageCache.set(message.id, fullMessage);
}
const messagePart = noBody ? omit(fullMessage, ["body", "attachments"]) : fullMessage;
return new MailboxMessage(messagePart);
}));
};
this.waitForMessagesWithSubject = async (subject: string, options?: { noBody?: boolean }) => {
return await this.waitForMessagesWithSubjectCount(subject, 1, options);
};
this.waitForMessagesWithSubjectCount = async (subject: string, minCount: number, options?: { noBody?: boolean }) => {
const timeoutMs = Number(process.env.STACK_MAILBOX_WAIT_TIMEOUT_MS ?? 60000);
const intervalMs = 500;
const deadline = Date.now() + timeoutMs;
let messages: MailboxMessage[] = [];
while (true) {
messages = await this.fetchMessages(options);
const withSubject = messages.filter(m => m.subject.includes(subject));
if (withSubject.length >= minCount) {
return withSubject;
}
if (Date.now() >= deadline) break;
await wait(intervalMs);
}
throw new HexclaveAssertionError(`Expected at least ${minCount} messages with subject containing "${subject}", but found ${messages.filter(m => m.subject.includes(subject)).length}`, { messages });
};
}
}
export class MailboxMessage {
declare public readonly subject: string;
declare public readonly from: string;
declare public readonly to: string;
declare public readonly date: string;
declare public readonly id: string;
declare public readonly size: number;
declare public readonly seen: boolean;
declare public readonly "posix-millis": number;
declare public readonly header?: any;
declare public readonly body?: { text: string, html: string };
declare public readonly attachments?: any[];
constructor(json: any) {
Object.assign(this, json);
}
getSnapshotSerializerOptions() {
return ({
stripFields: [],
hideFields: [
"posix-millis",
"header",
"date",
"mailbox",
"id",
"size",
"seen",
],
});
};
}
function expandHexclavePortPrefix(value?: string | null) {
if (!value) return value ?? undefined;
const prefix = getEnvVariable("NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX", "81");
return prefix ? value.replace(/\$\{NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81\}/g, prefix) : value;
}
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith("STACK_") || key.startsWith("NEXT_PUBLIC_STACK_")) {
const replaced = expandHexclavePortPrefix(value ?? undefined);
if (replaced !== undefined) {
// eslint-disable-next-line no-restricted-syntax
process.env[key] = replaced;
}
}
}
export const STACK_DASHBOARD_BASE_URL = getEnvVariable("STACK_DASHBOARD_BASE_URL");
export const STACK_BACKEND_BASE_URL = getEnvVariable("STACK_BACKEND_BASE_URL");
export const STACK_MCP_BASE_URL = getEnvVariable("STACK_MCP_BASE_URL");
/**
* The `baseUrl` to pass to SDK constructors (`StackClientApp`, `StackServerApp`,
* `StackAdminApp`) in JS-SDK e2e tests.
*
* Normally this is `STACK_BACKEND_BASE_URL` (single, explicit URL).
*
* In the e2e-fallback-tests workflow (`STACK_TEST_SDK_FALLBACK=true`) we leave
* this `undefined` so the SDK resolves the base URL from
* `NEXT_PUBLIC_STACK_API_URL` *and* appends its hardcoded fallback URL list,
* which is what the workflow exercises by running the backend only on the
* fallback port. Always thread this through to SDK constructors instead of
* hardcoding `STACK_BACKEND_BASE_URL`.
*/
export const SDK_BASE_URL: string | undefined = process.env.STACK_TEST_SDK_FALLBACK
? undefined
: STACK_BACKEND_BASE_URL;
export const STACK_INTERNAL_PROJECT_ID = getEnvVariable("STACK_INTERNAL_PROJECT_ID");
export const STACK_INTERNAL_PROJECT_CLIENT_KEY = getEnvVariable("STACK_INTERNAL_PROJECT_CLIENT_KEY");
export const STACK_INTERNAL_PROJECT_SERVER_KEY = getEnvVariable("STACK_INTERNAL_PROJECT_SERVER_KEY");
export const STACK_INTERNAL_PROJECT_ADMIN_KEY = getEnvVariable("STACK_INTERNAL_PROJECT_ADMIN_KEY");
export const STACK_INBUCKET_API_URL = getEnvVariable("STACK_INBUCKET_API_URL");
export const STACK_SVIX_SERVER_URL = getEnvVariable("STACK_SVIX_SERVER_URL");