@kiwa-test/nextjs 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/index.cjs +80 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +54 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/package.json +80 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cardene777
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @kiwa-test/nextjs
|
|
2
|
+
|
|
3
|
+
Next.js App Router test adapter for [kiwa](https://github.com/cardene777/kiwa) — invoke Server Actions in isolation and capture redirect / cookie / header side-effects without a running Next.js server.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add -D @kiwa-test/nextjs
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
Next.js Server Actions (`'use server'`) are async functions that can throw `redirect()`, mutate cookies, and call `revalidatePath()` — none of which return values. Integration-level testing through Playwright works but is slow and flaky. `@kiwa-test/nextjs` lets you call the action **directly in Vitest** and assert on the captured side-effects.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { describe, expect, it } from 'vitest';
|
|
17
|
+
import { invokeServerAction, REDIRECT_SYMBOL } from '@kiwa-test/nextjs';
|
|
18
|
+
|
|
19
|
+
// app/actions.ts — your Server Action
|
|
20
|
+
async function login(formData: FormData) {
|
|
21
|
+
const email = formData.get('email') as string;
|
|
22
|
+
if (!email) throw new Error('email required');
|
|
23
|
+
// In production: redirect('/dashboard') from 'next/navigation'.
|
|
24
|
+
// In tests: throw a kiwa redirect signal so the helper can capture it.
|
|
25
|
+
throw {
|
|
26
|
+
[REDIRECT_SYMBOL]: true,
|
|
27
|
+
url: '/dashboard',
|
|
28
|
+
type: 'replace',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('login', () => {
|
|
33
|
+
it('redirects to /dashboard on success', async () => {
|
|
34
|
+
const fd = new FormData();
|
|
35
|
+
fd.set('email', 'user@example.com');
|
|
36
|
+
const { env, error } = await invokeServerAction({ action: login, formData: fd });
|
|
37
|
+
expect(error).toBeUndefined();
|
|
38
|
+
expect(env.redirect?.url).toBe('/dashboard');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns validation error when email is missing', async () => {
|
|
42
|
+
const { error } = await invokeServerAction({ action: login, formData: new FormData() });
|
|
43
|
+
expect((error as Error).message).toBe('email required');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
### `invokeServerAction<TResult>(opts): Promise<ServerActionResult<TResult>>`
|
|
51
|
+
|
|
52
|
+
Invokes a `'use server'` action and captures side-effects.
|
|
53
|
+
|
|
54
|
+
| `opts` field | Type | Default | Meaning |
|
|
55
|
+
|---|---|---|---|
|
|
56
|
+
| `action` | `(...args) => Promise<T> \| T` | required | The Server Action under test |
|
|
57
|
+
| `formData` | `FormData` | empty | First positional argument |
|
|
58
|
+
| `args` | `unknown[]` | `[]` | Extra args appended after `formData` (useful for `useFormState` `(prevState, formData)` shape) |
|
|
59
|
+
| `cookies` | `Record<string, string>` | `{}` | Initial cookie jar entries |
|
|
60
|
+
| `headers` | `Record<string, string>` | `{}` | Initial request headers (case-insensitive) |
|
|
61
|
+
|
|
62
|
+
The returned `ServerActionResult` exposes `result` (the resolved value), `error` (any non-redirect throw), and `env` (captured cookies / headers / redirect / revalidate signals).
|
|
63
|
+
|
|
64
|
+
### `REDIRECT_SYMBOL`
|
|
65
|
+
|
|
66
|
+
Throw a `{ [REDIRECT_SYMBOL]: true, url, type }` from your action to signal a redirect. The helper normalizes it into `env.redirect` instead of leaking it as an error. Production code keeps using `redirect()` from `next/navigation` — only the test seam differs.
|
|
67
|
+
|
|
68
|
+
## Out of scope (tracked separately)
|
|
69
|
+
|
|
70
|
+
- **React Server Components (RSC) render assertions** — [#494](https://github.com/cardene777/kiwa/issues/494)
|
|
71
|
+
- **`middleware.ts` invocation** — [#495](https://github.com/cardene777/kiwa/issues/495)
|
|
72
|
+
- **End-to-end browser flow after the action** — use `/kiwa-e2e` or `/kiwa-play` instead
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
REDIRECT_SYMBOL: () => REDIRECT_SYMBOL,
|
|
24
|
+
invokeServerAction: () => invokeServerAction
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/invoke-server-action.ts
|
|
29
|
+
var REDIRECT_SYMBOL = /* @__PURE__ */ Symbol.for("kiwa.next.redirect");
|
|
30
|
+
function createCookieJar(initial) {
|
|
31
|
+
const store = new Map(Object.entries(initial));
|
|
32
|
+
return {
|
|
33
|
+
get(name) {
|
|
34
|
+
return store.get(name);
|
|
35
|
+
},
|
|
36
|
+
set(name, value) {
|
|
37
|
+
store.set(name, value);
|
|
38
|
+
},
|
|
39
|
+
delete(name) {
|
|
40
|
+
store.delete(name);
|
|
41
|
+
},
|
|
42
|
+
entries() {
|
|
43
|
+
return Array.from(store.entries());
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function isRedirectSignal(value) {
|
|
48
|
+
return typeof value === "object" && value !== null && value[REDIRECT_SYMBOL] === true;
|
|
49
|
+
}
|
|
50
|
+
async function invokeServerAction(opts) {
|
|
51
|
+
const headers = /* @__PURE__ */ new Map();
|
|
52
|
+
for (const [name, value] of Object.entries(opts.headers ?? {})) {
|
|
53
|
+
headers.set(name.toLowerCase(), value);
|
|
54
|
+
}
|
|
55
|
+
const env = {
|
|
56
|
+
cookies: createCookieJar(opts.cookies ?? {}),
|
|
57
|
+
headers,
|
|
58
|
+
revalidated: { paths: [], tags: [] },
|
|
59
|
+
redirect: null
|
|
60
|
+
};
|
|
61
|
+
const callArgs = [opts.formData ?? new FormData(), ...opts.args ?? []];
|
|
62
|
+
let result;
|
|
63
|
+
let error;
|
|
64
|
+
try {
|
|
65
|
+
result = await opts.action(...callArgs);
|
|
66
|
+
} catch (caught) {
|
|
67
|
+
if (isRedirectSignal(caught)) {
|
|
68
|
+
env.redirect = caught;
|
|
69
|
+
} else {
|
|
70
|
+
error = caught;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { result, error, env };
|
|
74
|
+
}
|
|
75
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
76
|
+
0 && (module.exports = {
|
|
77
|
+
REDIRECT_SYMBOL,
|
|
78
|
+
invokeServerAction
|
|
79
|
+
});
|
|
80
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/invoke-server-action.ts"],"sourcesContent":["export {\n invokeServerAction,\n type ServerActionFunction,\n type ServerActionResult,\n type ServerActionInvocation,\n type ServerActionEnv,\n type CookieJar,\n REDIRECT_SYMBOL,\n type RedirectSignal,\n} from './invoke-server-action.js';\n","// Next.js Server Action invocation helper for kiwa tests.\n//\n// Wraps an `'use server'` async function and captures Next.js side-effects\n// (redirect / revalidatePath / cookies set) without requiring a real Next.js\n// runtime. The test calls `invokeServerAction({ action, formData, ... })`\n// and asserts on the returned `ServerActionResult` instead of relying on\n// integration-level behavior.\n//\n// Out of scope on purpose:\n// - real `next/server` middleware semantics (use #495 helper instead)\n// - RSC payload format (use #494 helper instead)\n// - rendering a page after the action (use Playwright + `/kiwa-e2e` for that)\n\nexport const REDIRECT_SYMBOL = Symbol.for('kiwa.next.redirect');\n\nexport interface RedirectSignal {\n readonly [REDIRECT_SYMBOL]: true;\n readonly url: string;\n readonly type: 'replace' | 'push';\n}\n\nexport interface CookieJar {\n get(name: string): string | undefined;\n set(name: string, value: string, options?: Record<string, unknown>): void;\n delete(name: string): void;\n entries(): Array<[string, string]>;\n}\n\nexport interface ServerActionEnv {\n readonly cookies: CookieJar;\n readonly headers: Map<string, string>;\n readonly revalidated: { paths: string[]; tags: string[] };\n readonly redirect: RedirectSignal | null;\n}\n\n// We deliberately accept `any[]` here so callers can pass typed actions like\n// `(fd: FormData) => Promise<T>` or `(prev: State, fd: FormData) => Promise<T>`\n// without contortions. The runtime always invokes `action(formData, ...args)`.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ServerActionFunction<TResult> = (...args: any[]) => Promise<TResult> | TResult;\n\nexport interface ServerActionInvocation<TResult> {\n /** The `'use server'` async function under test. */\n readonly action: ServerActionFunction<TResult>;\n /** Optional FormData first argument (default empty). */\n readonly formData?: FormData;\n /** Extra positional args appended after FormData (e.g. previous state for useFormState). */\n readonly args?: unknown[];\n /** Initial cookie jar entries (name → value). */\n readonly cookies?: Record<string, string>;\n /** Initial request headers (case-insensitive). */\n readonly headers?: Record<string, string>;\n}\n\nexport interface ServerActionResult<TResult> {\n /** Resolved return value (or `undefined` if the action threw a redirect signal). */\n readonly result: TResult | undefined;\n /** Error thrown by the action (excluding redirect signals which are normalized). */\n readonly error: unknown;\n /** Side-effects captured during the invocation. */\n readonly env: ServerActionEnv;\n}\n\nfunction createCookieJar(initial: Record<string, string>): CookieJar {\n const store = new Map<string, string>(Object.entries(initial));\n return {\n get(name) {\n return store.get(name);\n },\n set(name, value) {\n store.set(name, value);\n },\n delete(name) {\n store.delete(name);\n },\n entries() {\n return Array.from(store.entries());\n },\n };\n}\n\nfunction isRedirectSignal(value: unknown): value is RedirectSignal {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { [REDIRECT_SYMBOL]?: true })[REDIRECT_SYMBOL] === true\n );\n}\n\n/**\n * Invoke a Next.js Server Action in isolation and capture its side-effects.\n *\n * The action is called as `await action(formData, ...args)`. The kiwa helper\n * does NOT monkey-patch global `next/navigation` / `next/headers` / `next/cache`\n * imports. Instead the action under test should accept its dependencies via\n * an injectable seam (a parameter or a module-level setter) so tests stay\n * deterministic. See `examples/nextjs-server-actions-poc/` for the pattern.\n */\nexport async function invokeServerAction<TResult>(\n opts: ServerActionInvocation<TResult>,\n): Promise<ServerActionResult<TResult>> {\n const headers = new Map<string, string>();\n for (const [name, value] of Object.entries(opts.headers ?? {})) {\n headers.set(name.toLowerCase(), value);\n }\n const env: ServerActionEnv = {\n cookies: createCookieJar(opts.cookies ?? {}),\n headers,\n revalidated: { paths: [], tags: [] },\n redirect: null,\n };\n const callArgs: unknown[] = [opts.formData ?? new FormData(), ...(opts.args ?? [])];\n let result: TResult | undefined;\n let error: unknown;\n try {\n result = await opts.action(...callArgs);\n } catch (caught) {\n if (isRedirectSignal(caught)) {\n (env as { redirect: RedirectSignal | null }).redirect = caught;\n } else {\n error = caught;\n }\n }\n return { result, error, env };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACaO,IAAM,kBAAkB,uBAAO,IAAI,oBAAoB;AAkD9D,SAAS,gBAAgB,SAA4C;AACnE,QAAM,QAAQ,IAAI,IAAoB,OAAO,QAAQ,OAAO,CAAC;AAC7D,SAAO;AAAA,IACL,IAAI,MAAM;AACR,aAAO,MAAM,IAAI,IAAI;AAAA,IACvB;AAAA,IACA,IAAI,MAAM,OAAO;AACf,YAAM,IAAI,MAAM,KAAK;AAAA,IACvB;AAAA,IACA,OAAO,MAAM;AACX,YAAM,OAAO,IAAI;AAAA,IACnB;AAAA,IACA,UAAU;AACR,aAAO,MAAM,KAAK,MAAM,QAAQ,CAAC;AAAA,IACnC;AAAA,EACF;AACF;AAEA,SAAS,iBAAiB,OAAyC;AACjE,SACE,OAAO,UAAU,YACjB,UAAU,QACT,MAAuC,eAAe,MAAM;AAEjE;AAWA,eAAsB,mBACpB,MACsC;AACtC,QAAM,UAAU,oBAAI,IAAoB;AACxC,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,KAAK,WAAW,CAAC,CAAC,GAAG;AAC9D,YAAQ,IAAI,KAAK,YAAY,GAAG,KAAK;AAAA,EACvC;AACA,QAAM,MAAuB;AAAA,IAC3B,SAAS,gBAAgB,KAAK,WAAW,CAAC,CAAC;AAAA,IAC3C;AAAA,IACA,aAAa,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,EAAE;AAAA,IACnC,UAAU;AAAA,EACZ;AACA,QAAM,WAAsB,CAAC,KAAK,YAAY,IAAI,SAAS,GAAG,GAAI,KAAK,QAAQ,CAAC,CAAE;AAClF,MAAI;AACJ,MAAI;AACJ,MAAI;AACF,aAAS,MAAM,KAAK,OAAO,GAAG,QAAQ;AAAA,EACxC,SAAS,QAAQ;AACf,QAAI,iBAAiB,MAAM,GAAG;AAC5B,MAAC,IAA4C,WAAW;AAAA,IAC1D,OAAO;AACL,cAAQ;AAAA,IACV;AAAA,EACF;AACA,SAAO,EAAE,QAAQ,OAAO,IAAI;AAC9B;","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
declare const REDIRECT_SYMBOL: unique symbol;
|
|
2
|
+
interface RedirectSignal {
|
|
3
|
+
readonly [REDIRECT_SYMBOL]: true;
|
|
4
|
+
readonly url: string;
|
|
5
|
+
readonly type: 'replace' | 'push';
|
|
6
|
+
}
|
|
7
|
+
interface CookieJar {
|
|
8
|
+
get(name: string): string | undefined;
|
|
9
|
+
set(name: string, value: string, options?: Record<string, unknown>): void;
|
|
10
|
+
delete(name: string): void;
|
|
11
|
+
entries(): Array<[string, string]>;
|
|
12
|
+
}
|
|
13
|
+
interface ServerActionEnv {
|
|
14
|
+
readonly cookies: CookieJar;
|
|
15
|
+
readonly headers: Map<string, string>;
|
|
16
|
+
readonly revalidated: {
|
|
17
|
+
paths: string[];
|
|
18
|
+
tags: string[];
|
|
19
|
+
};
|
|
20
|
+
readonly redirect: RedirectSignal | null;
|
|
21
|
+
}
|
|
22
|
+
type ServerActionFunction<TResult> = (...args: any[]) => Promise<TResult> | TResult;
|
|
23
|
+
interface ServerActionInvocation<TResult> {
|
|
24
|
+
/** The `'use server'` async function under test. */
|
|
25
|
+
readonly action: ServerActionFunction<TResult>;
|
|
26
|
+
/** Optional FormData first argument (default empty). */
|
|
27
|
+
readonly formData?: FormData;
|
|
28
|
+
/** Extra positional args appended after FormData (e.g. previous state for useFormState). */
|
|
29
|
+
readonly args?: unknown[];
|
|
30
|
+
/** Initial cookie jar entries (name → value). */
|
|
31
|
+
readonly cookies?: Record<string, string>;
|
|
32
|
+
/** Initial request headers (case-insensitive). */
|
|
33
|
+
readonly headers?: Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
interface ServerActionResult<TResult> {
|
|
36
|
+
/** Resolved return value (or `undefined` if the action threw a redirect signal). */
|
|
37
|
+
readonly result: TResult | undefined;
|
|
38
|
+
/** Error thrown by the action (excluding redirect signals which are normalized). */
|
|
39
|
+
readonly error: unknown;
|
|
40
|
+
/** Side-effects captured during the invocation. */
|
|
41
|
+
readonly env: ServerActionEnv;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Invoke a Next.js Server Action in isolation and capture its side-effects.
|
|
45
|
+
*
|
|
46
|
+
* The action is called as `await action(formData, ...args)`. The kiwa helper
|
|
47
|
+
* does NOT monkey-patch global `next/navigation` / `next/headers` / `next/cache`
|
|
48
|
+
* imports. Instead the action under test should accept its dependencies via
|
|
49
|
+
* an injectable seam (a parameter or a module-level setter) so tests stay
|
|
50
|
+
* deterministic. See `examples/nextjs-server-actions-poc/` for the pattern.
|
|
51
|
+
*/
|
|
52
|
+
declare function invokeServerAction<TResult>(opts: ServerActionInvocation<TResult>): Promise<ServerActionResult<TResult>>;
|
|
53
|
+
|
|
54
|
+
export { type CookieJar, REDIRECT_SYMBOL, type RedirectSignal, type ServerActionEnv, type ServerActionFunction, type ServerActionInvocation, type ServerActionResult, invokeServerAction };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
declare const REDIRECT_SYMBOL: unique symbol;
|
|
2
|
+
interface RedirectSignal {
|
|
3
|
+
readonly [REDIRECT_SYMBOL]: true;
|
|
4
|
+
readonly url: string;
|
|
5
|
+
readonly type: 'replace' | 'push';
|
|
6
|
+
}
|
|
7
|
+
interface CookieJar {
|
|
8
|
+
get(name: string): string | undefined;
|
|
9
|
+
set(name: string, value: string, options?: Record<string, unknown>): void;
|
|
10
|
+
delete(name: string): void;
|
|
11
|
+
entries(): Array<[string, string]>;
|
|
12
|
+
}
|
|
13
|
+
interface ServerActionEnv {
|
|
14
|
+
readonly cookies: CookieJar;
|
|
15
|
+
readonly headers: Map<string, string>;
|
|
16
|
+
readonly revalidated: {
|
|
17
|
+
paths: string[];
|
|
18
|
+
tags: string[];
|
|
19
|
+
};
|
|
20
|
+
readonly redirect: RedirectSignal | null;
|
|
21
|
+
}
|
|
22
|
+
type ServerActionFunction<TResult> = (...args: any[]) => Promise<TResult> | TResult;
|
|
23
|
+
interface ServerActionInvocation<TResult> {
|
|
24
|
+
/** The `'use server'` async function under test. */
|
|
25
|
+
readonly action: ServerActionFunction<TResult>;
|
|
26
|
+
/** Optional FormData first argument (default empty). */
|
|
27
|
+
readonly formData?: FormData;
|
|
28
|
+
/** Extra positional args appended after FormData (e.g. previous state for useFormState). */
|
|
29
|
+
readonly args?: unknown[];
|
|
30
|
+
/** Initial cookie jar entries (name → value). */
|
|
31
|
+
readonly cookies?: Record<string, string>;
|
|
32
|
+
/** Initial request headers (case-insensitive). */
|
|
33
|
+
readonly headers?: Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
interface ServerActionResult<TResult> {
|
|
36
|
+
/** Resolved return value (or `undefined` if the action threw a redirect signal). */
|
|
37
|
+
readonly result: TResult | undefined;
|
|
38
|
+
/** Error thrown by the action (excluding redirect signals which are normalized). */
|
|
39
|
+
readonly error: unknown;
|
|
40
|
+
/** Side-effects captured during the invocation. */
|
|
41
|
+
readonly env: ServerActionEnv;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Invoke a Next.js Server Action in isolation and capture its side-effects.
|
|
45
|
+
*
|
|
46
|
+
* The action is called as `await action(formData, ...args)`. The kiwa helper
|
|
47
|
+
* does NOT monkey-patch global `next/navigation` / `next/headers` / `next/cache`
|
|
48
|
+
* imports. Instead the action under test should accept its dependencies via
|
|
49
|
+
* an injectable seam (a parameter or a module-level setter) so tests stay
|
|
50
|
+
* deterministic. See `examples/nextjs-server-actions-poc/` for the pattern.
|
|
51
|
+
*/
|
|
52
|
+
declare function invokeServerAction<TResult>(opts: ServerActionInvocation<TResult>): Promise<ServerActionResult<TResult>>;
|
|
53
|
+
|
|
54
|
+
export { type CookieJar, REDIRECT_SYMBOL, type RedirectSignal, type ServerActionEnv, type ServerActionFunction, type ServerActionInvocation, type ServerActionResult, invokeServerAction };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/invoke-server-action.ts
|
|
2
|
+
var REDIRECT_SYMBOL = /* @__PURE__ */ Symbol.for("kiwa.next.redirect");
|
|
3
|
+
function createCookieJar(initial) {
|
|
4
|
+
const store = new Map(Object.entries(initial));
|
|
5
|
+
return {
|
|
6
|
+
get(name) {
|
|
7
|
+
return store.get(name);
|
|
8
|
+
},
|
|
9
|
+
set(name, value) {
|
|
10
|
+
store.set(name, value);
|
|
11
|
+
},
|
|
12
|
+
delete(name) {
|
|
13
|
+
store.delete(name);
|
|
14
|
+
},
|
|
15
|
+
entries() {
|
|
16
|
+
return Array.from(store.entries());
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function isRedirectSignal(value) {
|
|
21
|
+
return typeof value === "object" && value !== null && value[REDIRECT_SYMBOL] === true;
|
|
22
|
+
}
|
|
23
|
+
async function invokeServerAction(opts) {
|
|
24
|
+
const headers = /* @__PURE__ */ new Map();
|
|
25
|
+
for (const [name, value] of Object.entries(opts.headers ?? {})) {
|
|
26
|
+
headers.set(name.toLowerCase(), value);
|
|
27
|
+
}
|
|
28
|
+
const env = {
|
|
29
|
+
cookies: createCookieJar(opts.cookies ?? {}),
|
|
30
|
+
headers,
|
|
31
|
+
revalidated: { paths: [], tags: [] },
|
|
32
|
+
redirect: null
|
|
33
|
+
};
|
|
34
|
+
const callArgs = [opts.formData ?? new FormData(), ...opts.args ?? []];
|
|
35
|
+
let result;
|
|
36
|
+
let error;
|
|
37
|
+
try {
|
|
38
|
+
result = await opts.action(...callArgs);
|
|
39
|
+
} catch (caught) {
|
|
40
|
+
if (isRedirectSignal(caught)) {
|
|
41
|
+
env.redirect = caught;
|
|
42
|
+
} else {
|
|
43
|
+
error = caught;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { result, error, env };
|
|
47
|
+
}
|
|
48
|
+
export {
|
|
49
|
+
REDIRECT_SYMBOL,
|
|
50
|
+
invokeServerAction
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/invoke-server-action.ts"],"sourcesContent":["// Next.js Server Action invocation helper for kiwa tests.\n//\n// Wraps an `'use server'` async function and captures Next.js side-effects\n// (redirect / revalidatePath / cookies set) without requiring a real Next.js\n// runtime. The test calls `invokeServerAction({ action, formData, ... })`\n// and asserts on the returned `ServerActionResult` instead of relying on\n// integration-level behavior.\n//\n// Out of scope on purpose:\n// - real `next/server` middleware semantics (use #495 helper instead)\n// - RSC payload format (use #494 helper instead)\n// - rendering a page after the action (use Playwright + `/kiwa-e2e` for that)\n\nexport const REDIRECT_SYMBOL = Symbol.for('kiwa.next.redirect');\n\nexport interface RedirectSignal {\n readonly [REDIRECT_SYMBOL]: true;\n readonly url: string;\n readonly type: 'replace' | 'push';\n}\n\nexport interface CookieJar {\n get(name: string): string | undefined;\n set(name: string, value: string, options?: Record<string, unknown>): void;\n delete(name: string): void;\n entries(): Array<[string, string]>;\n}\n\nexport interface ServerActionEnv {\n readonly cookies: CookieJar;\n readonly headers: Map<string, string>;\n readonly revalidated: { paths: string[]; tags: string[] };\n readonly redirect: RedirectSignal | null;\n}\n\n// We deliberately accept `any[]` here so callers can pass typed actions like\n// `(fd: FormData) => Promise<T>` or `(prev: State, fd: FormData) => Promise<T>`\n// without contortions. The runtime always invokes `action(formData, ...args)`.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ServerActionFunction<TResult> = (...args: any[]) => Promise<TResult> | TResult;\n\nexport interface ServerActionInvocation<TResult> {\n /** The `'use server'` async function under test. */\n readonly action: ServerActionFunction<TResult>;\n /** Optional FormData first argument (default empty). */\n readonly formData?: FormData;\n /** Extra positional args appended after FormData (e.g. previous state for useFormState). */\n readonly args?: unknown[];\n /** Initial cookie jar entries (name → value). */\n readonly cookies?: Record<string, string>;\n /** Initial request headers (case-insensitive). */\n readonly headers?: Record<string, string>;\n}\n\nexport interface ServerActionResult<TResult> {\n /** Resolved return value (or `undefined` if the action threw a redirect signal). */\n readonly result: TResult | undefined;\n /** Error thrown by the action (excluding redirect signals which are normalized). */\n readonly error: unknown;\n /** Side-effects captured during the invocation. */\n readonly env: ServerActionEnv;\n}\n\nfunction createCookieJar(initial: Record<string, string>): CookieJar {\n const store = new Map<string, string>(Object.entries(initial));\n return {\n get(name) {\n return store.get(name);\n },\n set(name, value) {\n store.set(name, value);\n },\n delete(name) {\n store.delete(name);\n },\n entries() {\n return Array.from(store.entries());\n },\n };\n}\n\nfunction isRedirectSignal(value: unknown): value is RedirectSignal {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { [REDIRECT_SYMBOL]?: true })[REDIRECT_SYMBOL] === true\n );\n}\n\n/**\n * Invoke a Next.js Server Action in isolation and capture its side-effects.\n *\n * The action is called as `await action(formData, ...args)`. The kiwa helper\n * does NOT monkey-patch global `next/navigation` / `next/headers` / `next/cache`\n * imports. Instead the action under test should accept its dependencies via\n * an injectable seam (a parameter or a module-level setter) so tests stay\n * deterministic. See `examples/nextjs-server-actions-poc/` for the pattern.\n */\nexport async function invokeServerAction<TResult>(\n opts: ServerActionInvocation<TResult>,\n): Promise<ServerActionResult<TResult>> {\n const headers = new Map<string, string>();\n for (const [name, value] of Object.entries(opts.headers ?? {})) {\n headers.set(name.toLowerCase(), value);\n }\n const env: ServerActionEnv = {\n cookies: createCookieJar(opts.cookies ?? {}),\n headers,\n revalidated: { paths: [], tags: [] },\n redirect: null,\n };\n const callArgs: unknown[] = [opts.formData ?? new FormData(), ...(opts.args ?? [])];\n let result: TResult | undefined;\n let error: unknown;\n try {\n result = await opts.action(...callArgs);\n } catch (caught) {\n if (isRedirectSignal(caught)) {\n (env as { redirect: RedirectSignal | null }).redirect = caught;\n } else {\n error = caught;\n }\n }\n return { result, error, env };\n}\n"],"mappings":";AAaO,IAAM,kBAAkB,uBAAO,IAAI,oBAAoB;AAkD9D,SAAS,gBAAgB,SAA4C;AACnE,QAAM,QAAQ,IAAI,IAAoB,OAAO,QAAQ,OAAO,CAAC;AAC7D,SAAO;AAAA,IACL,IAAI,MAAM;AACR,aAAO,MAAM,IAAI,IAAI;AAAA,IACvB;AAAA,IACA,IAAI,MAAM,OAAO;AACf,YAAM,IAAI,MAAM,KAAK;AAAA,IACvB;AAAA,IACA,OAAO,MAAM;AACX,YAAM,OAAO,IAAI;AAAA,IACnB;AAAA,IACA,UAAU;AACR,aAAO,MAAM,KAAK,MAAM,QAAQ,CAAC;AAAA,IACnC;AAAA,EACF;AACF;AAEA,SAAS,iBAAiB,OAAyC;AACjE,SACE,OAAO,UAAU,YACjB,UAAU,QACT,MAAuC,eAAe,MAAM;AAEjE;AAWA,eAAsB,mBACpB,MACsC;AACtC,QAAM,UAAU,oBAAI,IAAoB;AACxC,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,KAAK,WAAW,CAAC,CAAC,GAAG;AAC9D,YAAQ,IAAI,KAAK,YAAY,GAAG,KAAK;AAAA,EACvC;AACA,QAAM,MAAuB;AAAA,IAC3B,SAAS,gBAAgB,KAAK,WAAW,CAAC,CAAC;AAAA,IAC3C;AAAA,IACA,aAAa,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,EAAE;AAAA,IACnC,UAAU;AAAA,EACZ;AACA,QAAM,WAAsB,CAAC,KAAK,YAAY,IAAI,SAAS,GAAG,GAAI,KAAK,QAAQ,CAAC,CAAE;AAClF,MAAI;AACJ,MAAI;AACJ,MAAI;AACF,aAAS,MAAM,KAAK,OAAO,GAAG,QAAQ;AAAA,EACxC,SAAS,QAAQ;AACf,QAAI,iBAAiB,MAAM,GAAG;AAC5B,MAAC,IAA4C,WAAW;AAAA,IAC1D,OAAO;AACL,cAAQ;AAAA,IACV;AAAA,EACF;AACA,SAAO,EAAE,QAAQ,OAAO,IAAI;AAC9B;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kiwa-test/nextjs",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Next.js App Router test adapter for kiwa (Server Actions / RSC / middleware) — invoke Server Actions with FormData / headers / cookies and assert redirect / revalidate behavior",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "cardene",
|
|
8
|
+
"url": "https://github.com/cardene777"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"kiwa",
|
|
12
|
+
"nextjs",
|
|
13
|
+
"next.js",
|
|
14
|
+
"app-router",
|
|
15
|
+
"server-actions",
|
|
16
|
+
"rsc",
|
|
17
|
+
"react-server-components",
|
|
18
|
+
"middleware",
|
|
19
|
+
"testing",
|
|
20
|
+
"vitest"
|
|
21
|
+
],
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/cardene777/kiwa.git",
|
|
25
|
+
"directory": "packages/nextjs"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/cardene777/kiwa#readme",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/cardene777/kiwa/issues"
|
|
30
|
+
},
|
|
31
|
+
"type": "module",
|
|
32
|
+
"main": "./dist/index.cjs",
|
|
33
|
+
"module": "./dist/index.js",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"import": {
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"default": "./dist/index.js"
|
|
40
|
+
},
|
|
41
|
+
"require": {
|
|
42
|
+
"types": "./dist/index.d.cts",
|
|
43
|
+
"default": "./dist/index.cjs"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"dist",
|
|
49
|
+
"README.md"
|
|
50
|
+
],
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public",
|
|
53
|
+
"provenance": true
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=20"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@kiwa-test/core": "1.0.1"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"vitest": "^2"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@stryker-mutator/core": "^8.7.1",
|
|
66
|
+
"@stryker-mutator/vitest-runner": "^8.7.1",
|
|
67
|
+
"@types/node": "^22.10.0",
|
|
68
|
+
"@vitest/coverage-v8": "^2.1.0",
|
|
69
|
+
"tsup": "^8.3.0",
|
|
70
|
+
"typescript": "^5.6.0",
|
|
71
|
+
"vitest": "^2.1.0"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"build": "tsup",
|
|
75
|
+
"test": "node -e \"require('node:fs').rmSync('.vitest-dist',{recursive:true,force:true})\" && tsc -p tsconfig.vitest.json && vitest run .vitest-dist/tests --environment node",
|
|
76
|
+
"test:cov": "node -e \"require('node:fs').rmSync('.vitest-dist',{recursive:true,force:true})\" && tsc -p tsconfig.vitest.json && vitest run .vitest-dist/tests --environment node --coverage --coverage.provider=v8 --coverage.reporter=json --coverage.reporter=json-summary --coverage.reporter=text --coverage.reportsDirectory=coverage --coverage.include='.vitest-dist/src/invoke-server-action.js' --coverage.exclude='.vitest-dist/tests/**' --coverage.exclude='.vitest-dist/src/index.js'",
|
|
77
|
+
"test:mutation": "stryker run",
|
|
78
|
+
"typecheck": "tsc --noEmit"
|
|
79
|
+
}
|
|
80
|
+
}
|