@multiplatform.one/config 6.0.0

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.
@@ -0,0 +1,239 @@
1
+ import { defineConfig, devices } from "@playwright/test";
2
+ import type { PlaywrightTestConfig } from "@playwright/test";
3
+
4
+ export type PlaywrightBrowser =
5
+ | "chromium"
6
+ | "firefox"
7
+ | "webkit"
8
+ | "mobile-chrome"
9
+ | "mobile-safari";
10
+
11
+ export interface CreatePlaywrightConfigOptions {
12
+ /**
13
+ * Test directory path. Defaults to "./tests/e2e".
14
+ */
15
+ testDir?: string;
16
+
17
+ /**
18
+ * Base URL for tests. Defaults to process.env.BASE_URL || "http://localhost:8000".
19
+ */
20
+ baseURL?: string;
21
+
22
+ /**
23
+ * Browsers to test against.
24
+ * Defaults to ["chromium", "firefox", "webkit", "mobile-chrome", "mobile-safari"].
25
+ */
26
+ browsers?: PlaywrightBrowser[];
27
+
28
+ /**
29
+ * Number of retries. Defaults to 2 in CI, 1 locally.
30
+ */
31
+ retries?: number;
32
+
33
+ /**
34
+ * Number of parallel workers. Defaults to 1.
35
+ */
36
+ workers?: number;
37
+
38
+ /**
39
+ * Global test timeout in ms. Defaults to 60000.
40
+ */
41
+ timeout?: number;
42
+
43
+ /**
44
+ * Run tests fully in parallel. Defaults to false.
45
+ */
46
+ fullyParallel?: boolean;
47
+
48
+ /**
49
+ * Reporter to use. Defaults to "html".
50
+ */
51
+ reporter?: PlaywrightTestConfig["reporter"];
52
+
53
+ /**
54
+ * Run in headless mode. Defaults to true.
55
+ */
56
+ headless?: boolean;
57
+
58
+ /**
59
+ * Trace collection strategy. Defaults to "on-first-retry".
60
+ */
61
+ trace?:
62
+ | "on"
63
+ | "off"
64
+ | "on-first-retry"
65
+ | "on-all-retries"
66
+ | "retain-on-failure"
67
+ | "retain-on-first-failure";
68
+
69
+ /**
70
+ * Screenshot strategy. Defaults to "only-on-failure".
71
+ */
72
+ screenshot?: "on" | "off" | "only-on-failure";
73
+
74
+ /**
75
+ * Navigation timeout in ms. Defaults to 30000.
76
+ */
77
+ navigationTimeout?: number;
78
+
79
+ /**
80
+ * Action timeout in ms (clicks, fills, etc). Defaults to 10000.
81
+ */
82
+ actionTimeout?: number;
83
+
84
+ /**
85
+ * Forbid test.only in CI. Defaults to true when process.env.CI is set.
86
+ */
87
+ forbidOnly?: boolean;
88
+
89
+ /**
90
+ * Web server configuration to start before tests.
91
+ */
92
+ webServer?: PlaywrightTestConfig["webServer"];
93
+
94
+ /**
95
+ * Path to a module that is run once before all test files.
96
+ * Useful for global setup like generating auth tokens.
97
+ */
98
+ globalSetup?: string;
99
+
100
+ /**
101
+ * Glob patterns or regexps matching files to ignore.
102
+ * Defaults to undefined (no ignore).
103
+ */
104
+ testIgnore?: PlaywrightTestConfig["testIgnore"];
105
+
106
+ /**
107
+ * Override projects entirely (ignores the browsers option when provided).
108
+ */
109
+ projects?: PlaywrightTestConfig["projects"];
110
+
111
+ /**
112
+ * Additional shared use options merged with defaults.
113
+ */
114
+ use?: PlaywrightTestConfig["use"];
115
+ }
116
+
117
+ const chromiumSandboxArgs = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"];
118
+
119
+ function browserToProject(
120
+ browser: PlaywrightBrowser,
121
+ ): NonNullable<PlaywrightTestConfig["projects"]>[number] {
122
+ switch (browser) {
123
+ case "chromium":
124
+ return {
125
+ name: "chromium",
126
+ use: {
127
+ ...devices["Desktop Chrome"],
128
+ launchOptions: { args: chromiumSandboxArgs },
129
+ },
130
+ };
131
+ case "firefox":
132
+ return {
133
+ name: "firefox",
134
+ use: {
135
+ ...devices["Desktop Firefox"],
136
+ launchOptions: { args: chromiumSandboxArgs },
137
+ },
138
+ };
139
+ case "webkit":
140
+ return {
141
+ name: "webkit",
142
+ use: { ...devices["Desktop Safari"] },
143
+ };
144
+ case "mobile-chrome":
145
+ return {
146
+ name: "Mobile Chrome",
147
+ use: {
148
+ ...devices["Pixel 5"],
149
+ launchOptions: { args: chromiumSandboxArgs },
150
+ },
151
+ };
152
+ case "mobile-safari":
153
+ return {
154
+ name: "Mobile Safari",
155
+ use: { ...devices["iPhone 12"] },
156
+ };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Creates a shared Playwright configuration for multiplatform.one apps.
162
+ *
163
+ * This abstracts away the boilerplate multi-browser Playwright config,
164
+ * providing sensible defaults for CI/local environments, browser projects,
165
+ * timeouts, and trace/screenshot settings.
166
+ *
167
+ * @example
168
+ * ```ts
169
+ * // apps/one/playwright.config.ts
170
+ * import { createPlaywrightConfig } from "@multiplatform.one/config/playwright";
171
+ *
172
+ * export default createPlaywrightConfig();
173
+ * ```
174
+ *
175
+ * @example
176
+ * ```ts
177
+ * // Custom configuration
178
+ * import { createPlaywrightConfig } from "@multiplatform.one/config/playwright";
179
+ *
180
+ * export default createPlaywrightConfig({
181
+ * testDir: "./tests",
182
+ * browsers: ["chromium", "firefox"],
183
+ * webServer: {
184
+ * command: "pnpm dev --port 3000",
185
+ * url: "http://localhost:3000",
186
+ * reuseExistingServer: true,
187
+ * },
188
+ * });
189
+ * ```
190
+ */
191
+ export function createPlaywrightConfig(
192
+ options: CreatePlaywrightConfigOptions = {},
193
+ ): PlaywrightTestConfig {
194
+ const isCI = !!process.env.CI;
195
+ const {
196
+ testDir = "./tests/e2e",
197
+ baseURL = process.env.BASE_URL || "http://localhost:8000",
198
+ browsers = ["chromium", "firefox", "webkit", "mobile-chrome", "mobile-safari"],
199
+ retries = isCI ? 2 : 1,
200
+ workers = 1,
201
+ timeout = 60000,
202
+ fullyParallel = false,
203
+ reporter = "html",
204
+ headless = true,
205
+ trace = "on-first-retry",
206
+ screenshot = "only-on-failure",
207
+ navigationTimeout = 30000,
208
+ actionTimeout = 10000,
209
+ forbidOnly = isCI,
210
+ webServer,
211
+ globalSetup,
212
+ testIgnore,
213
+ projects,
214
+ use,
215
+ } = options;
216
+
217
+ return defineConfig({
218
+ testDir,
219
+ fullyParallel,
220
+ forbidOnly,
221
+ retries,
222
+ workers,
223
+ reporter,
224
+ timeout,
225
+ use: {
226
+ baseURL,
227
+ trace,
228
+ screenshot,
229
+ navigationTimeout,
230
+ actionTimeout,
231
+ headless,
232
+ ...use,
233
+ },
234
+ projects: projects ?? browsers.map(browserToProject),
235
+ ...(webServer ? { webServer } : {}),
236
+ ...(globalSetup ? { globalSetup } : {}),
237
+ ...(testIgnore ? { testIgnore } : {}),
238
+ });
239
+ }
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ module.exports = {
4
+ commands: [...require("vxrn/react-native-commands")],
5
+ };
@@ -0,0 +1,318 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { Plugin, UserConfig } from "vite";
4
+ import { discoverPublicPackageRoots, resolvePackageMainSource } from "./workspacePublicPackages.js";
5
+
6
+ export interface CreateStorybookViteConfigOptions {
7
+ /**
8
+ * Absolute path to the monorepo workspace root.
9
+ * Auto-detected by walking up from cwd looking for pnpm-workspace.yaml.
10
+ */
11
+ workspaceRoot?: string;
12
+
13
+ /**
14
+ * Additional resolve aliases merged after auto-discovered ones.
15
+ * Use this for packages outside the standard workspace structure.
16
+ */
17
+ aliases?: Record<string, string>;
18
+
19
+ /**
20
+ * Additional Vite plugins appended after the built-in React plugin.
21
+ */
22
+ plugins?: UserConfig["plugins"];
23
+
24
+ /**
25
+ * Additional Vite define values merged with the defaults.
26
+ */
27
+ define?: UserConfig["define"];
28
+ }
29
+
30
+ /**
31
+ * Creates a Vite configuration for Storybook in the multiplatform.one monorepo.
32
+ *
33
+ * Auto-discovers packages/ entries (multiplatform.one, package, and root
34
+ * multiplatform.one scopes) and every workspace package under public/ with a
35
+ * package.json name (including other scopes such as bitspur). Aliases point at
36
+ * TypeScript source; sub-path exports come from each package exports field.
37
+ *
38
+ * Includes React plugin, node:async_hooks stub, and Storybook optimizeDeps.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * // apps/storybook/vite.config.ts
43
+ * import { createStorybookViteConfig } from "@multiplatform.one/config/storybook";
44
+ *
45
+ * export default createStorybookViteConfig();
46
+ * ```
47
+ */
48
+ export function createStorybookViteConfig(
49
+ options: CreateStorybookViteConfigOptions = {},
50
+ ): UserConfig {
51
+ const workspaceRoot = options.workspaceRoot || findWorkspaceRoot();
52
+ const packagesDir = path.join(workspaceRoot, "packages");
53
+
54
+ const aliases = {
55
+ ...discoverPackageAliases(packagesDir),
56
+ ...discoverPublicPackageViteAliases(workspaceRoot),
57
+ };
58
+
59
+ if (options.aliases) {
60
+ Object.assign(aliases, options.aliases);
61
+ }
62
+
63
+ // Sort by key length descending so more-specific aliases match first
64
+ const sortedAliases: Record<string, string> = {};
65
+ for (const key of Object.keys(aliases).sort((a, b) => b.length - a.length)) {
66
+ sortedAliases[key] = aliases[key];
67
+ }
68
+
69
+ return {
70
+ define: {
71
+ "process.env.VITE_ENVIRONMENT": JSON.stringify("client"),
72
+ ...options.define,
73
+ },
74
+ resolve: {
75
+ // Do NOT add "source" here. Workspace packages are already mapped to
76
+ // their TypeScript source via resolve.alias. Adding "source" to the
77
+ // global conditions causes third-party packages like @react-navigation/*
78
+ // (which also have a "source" export condition pointing to .tsx files)
79
+ // to resolve to TypeScript. Vite's dep optimizer cannot pre-bundle .tsx
80
+ // files and logs "Cannot optimize dependency", leaving their CJS
81
+ // transitive deps (color, query-string, etc.) to be served raw,
82
+ // causing "require is not defined" / missing-export errors in the browser.
83
+ //
84
+ // "default" must be included: Rolldown 8 strictly enforces exports fields
85
+ // and requires "default" in the conditions list to resolve unconditional
86
+ // (plain-string) sub-path exports, e.g. @react-navigation/core's
87
+ // "./lib/module/EnsureSingleNavigator" entry.
88
+ conditions: ["default"],
89
+ extensions: [
90
+ ".storybook.ts",
91
+ ".storybook.tsx",
92
+ ".storybook.js",
93
+ ".storybook.jsx",
94
+ ".web.ts",
95
+ ".web.tsx",
96
+ ".web.js",
97
+ ".web.jsx",
98
+ ".ts",
99
+ ".tsx",
100
+ ".js",
101
+ ".jsx",
102
+ ".mjs",
103
+ ".json",
104
+ ],
105
+ alias: sortedAliases,
106
+ },
107
+ optimizeDeps: {
108
+ include: [
109
+ // Force dedup — these have ESM exports but must share a single instance
110
+ "react",
111
+ "react-dom",
112
+ "react-native-web",
113
+ "@tamagui/core",
114
+ "@tamagui/web",
115
+ "@tamagui/helpers-icon",
116
+ "tamagui",
117
+ "@mdx-js/react",
118
+ "i18next",
119
+ "react-i18next",
120
+ "@storybook-community/storybook-dark-mode",
121
+ // Packages with broken ESM wrappers — advertise "import" condition but
122
+ // the .mjs entry just re-imports a CJS file, or are ESM-only with no
123
+ // default export, so Vite must pre-bundle them for interop
124
+ "use-latest-callback",
125
+ "escape-string-regexp",
126
+ // Pure CJS with no "import" condition — must be pre-bundled so Rolldown
127
+ // generates ESM wrappers with synthetic named exports.
128
+ "use-sync-external-store",
129
+ "use-sync-external-store/with-selector",
130
+ "fast-deep-equal",
131
+ "color",
132
+ // query-string v7 is CJS-only (require/exports), pulled in by
133
+ // @react-navigation/core for URL parsing. Unlike the One app which
134
+ // aliases it to @vxrn/query-string (ESM), Storybook has no such alias.
135
+ "query-string",
136
+ "react-is",
137
+ ],
138
+ // Rolldown rc.10+ enforces exports fields strictly — @react-navigation/core has
139
+ // internal files (EnsureSingleNavigator etc.) not listed in its exports map,
140
+ // causing dep pre-bundle to crash. Exclude the nav packages from pre-bundling;
141
+ // their CJS transitive deps (color, use-sync-external-store, fast-deep-equal)
142
+ // are already explicitly in include above so they are still pre-bundled.
143
+ exclude: [
144
+ "one/dist/esm/vite/one-server-only.mjs",
145
+ "@storybook/preview-api",
146
+ "@storybook/theming",
147
+ "@react-navigation/core",
148
+ "@react-navigation/native",
149
+ "@react-navigation/routers",
150
+ "@react-navigation/elements",
151
+ "@react-navigation/bottom-tabs",
152
+ "@react-navigation/native-stack",
153
+ ],
154
+ },
155
+ plugins: [nodeAsyncHooksStub(), ...(options.plugins || [])],
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Scans packages/ for workspace packages (multiplatform.one and package scopes)
161
+ * and returns resolve aliases.
162
+ */
163
+ function discoverPackageAliases(packagesDir: string): Record<string, string> {
164
+ const aliases: Record<string, string> = {};
165
+
166
+ if (!fs.existsSync(packagesDir)) return aliases;
167
+
168
+ const entries = fs.readdirSync(packagesDir, { withFileTypes: true });
169
+
170
+ for (const entry of entries) {
171
+ if (!entry.isDirectory()) continue;
172
+
173
+ const pkgDir = path.join(packagesDir, entry.name);
174
+ const pkgJsonPath = path.join(pkgDir, "package.json");
175
+
176
+ if (!fs.existsSync(pkgJsonPath)) continue;
177
+
178
+ let pkgJson: { name?: string; exports?: Record<string, unknown> };
179
+ try {
180
+ pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
181
+ } catch {
182
+ continue;
183
+ }
184
+
185
+ const pkgName = pkgJson.name;
186
+ if (!pkgName) continue;
187
+ if (
188
+ !pkgName.startsWith("@multiplatform.one/") &&
189
+ !pkgName.startsWith("@package/") &&
190
+ pkgName !== "multiplatform.one"
191
+ )
192
+ continue;
193
+
194
+ mergePackageResolveAliases(pkgDir, pkgName, pkgJson, aliases);
195
+ }
196
+
197
+ return aliases;
198
+ }
199
+
200
+ /** All workspace packages under public/ (any npm scope), including export subpaths. */
201
+ function discoverPublicPackageViteAliases(workspaceRoot: string): Record<string, string> {
202
+ const aliases: Record<string, string> = {};
203
+ for (const [pkgName, pkgDir] of discoverPublicPackageRoots(workspaceRoot)) {
204
+ const pkgJsonPath = path.join(pkgDir, "package.json");
205
+ let pkgJson: { name?: string; exports?: Record<string, unknown> };
206
+ try {
207
+ pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
208
+ } catch {
209
+ continue;
210
+ }
211
+ mergePackageResolveAliases(pkgDir, pkgName, pkgJson, aliases);
212
+ }
213
+ return aliases;
214
+ }
215
+
216
+ function mergePackageResolveAliases(
217
+ pkgDir: string,
218
+ pkgName: string,
219
+ pkgJson: { exports?: Record<string, unknown> },
220
+ aliases: Record<string, string>,
221
+ ) {
222
+ const mainEntry = resolvePackageMainSource(pkgDir);
223
+ if (mainEntry) {
224
+ aliases[pkgName] = mainEntry;
225
+ }
226
+
227
+ if (pkgJson.exports && typeof pkgJson.exports === "object") {
228
+ for (const exportKey of Object.keys(pkgJson.exports)) {
229
+ if (exportKey === "." || exportKey === "./package.json") continue;
230
+
231
+ const subpath = exportKey.replace(/^\.\//, "");
232
+ const ext = path.extname(subpath);
233
+
234
+ if (ext) {
235
+ const parentDir = path.dirname(subpath);
236
+ if (parentDir && parentDir !== ".") {
237
+ const fullParentDir = path.join(pkgDir, parentDir);
238
+ if (fs.existsSync(fullParentDir) && fs.statSync(fullParentDir).isDirectory()) {
239
+ aliases[`${pkgName}/${parentDir}`] = fullParentDir;
240
+ }
241
+ }
242
+ } else {
243
+ const resolved = resolveSubpathSource(pkgDir, subpath);
244
+ if (resolved) {
245
+ aliases[`${pkgName}/${subpath}`] = resolved;
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ function resolveSubpathSource(pkgDir: string, subpath: string): string | undefined {
253
+ const candidates = [
254
+ path.join(pkgDir, "src", `${subpath}.storybook.ts`),
255
+ path.join(pkgDir, "src", `${subpath}.storybook.tsx`),
256
+ path.join(pkgDir, "src", `${subpath}.ts`),
257
+ path.join(pkgDir, "src", `${subpath}.tsx`),
258
+ path.join(pkgDir, "src", subpath, "index.storybook.ts"),
259
+ path.join(pkgDir, "src", subpath, "index.storybook.tsx"),
260
+ path.join(pkgDir, "src", subpath, "index.ts"),
261
+ path.join(pkgDir, "src", subpath, "index.tsx"),
262
+ path.join(pkgDir, `${subpath}.storybook.ts`),
263
+ path.join(pkgDir, `${subpath}.storybook.tsx`),
264
+ path.join(pkgDir, `${subpath}.ts`),
265
+ path.join(pkgDir, `${subpath}.tsx`),
266
+ path.join(pkgDir, subpath, "index.storybook.ts"),
267
+ path.join(pkgDir, subpath, "index.storybook.tsx"),
268
+ path.join(pkgDir, subpath, "index.ts"),
269
+ path.join(pkgDir, subpath, "index.tsx"),
270
+ path.join(pkgDir, subpath, "index.js"),
271
+ ];
272
+
273
+ for (const c of candidates) {
274
+ if (fs.existsSync(c)) return c;
275
+ }
276
+
277
+ const dirPath = path.join(pkgDir, subpath);
278
+ if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
279
+ return dirPath;
280
+ }
281
+
282
+ return undefined;
283
+ }
284
+
285
+ /** Virtual module plugin that stubs `node:async_hooks` for browser environments. */
286
+ function nodeAsyncHooksStub(): Plugin {
287
+ return {
288
+ name: "storybook:node-async-hooks-stub",
289
+ resolveId(id) {
290
+ if (id === "node:async_hooks") return "\0node:async_hooks";
291
+ },
292
+ load(id) {
293
+ if (id === "\0node:async_hooks") {
294
+ return "export class AsyncLocalStorage {}";
295
+ }
296
+ },
297
+ };
298
+ }
299
+
300
+ function findWorkspaceRoot(): string {
301
+ let dir = process.cwd();
302
+ while (dir !== path.dirname(dir)) {
303
+ if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) {
304
+ return dir;
305
+ }
306
+ const pkgJsonPath = path.join(dir, "package.json");
307
+ if (fs.existsSync(pkgJsonPath)) {
308
+ try {
309
+ const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
310
+ if (pkgJson.workspaces) return dir;
311
+ } catch {
312
+ // ignore parse errors
313
+ }
314
+ }
315
+ dir = path.dirname(dir);
316
+ }
317
+ return process.cwd();
318
+ }