@_linked/cli 1.4.2 → 1.4.3

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.
Files changed (84) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/defaults/app-static/src/index-static.tsx +22 -0
  3. package/defaults/app-with-backend/src/App.module.css +32 -0
  4. package/defaults/app-with-backend/src/App.tsx +30 -0
  5. package/defaults/app-with-backend/src/backend.ts +9 -0
  6. package/defaults/app-with-backend/src/components/Error.module.css +9 -0
  7. package/defaults/app-with-backend/src/components/Error.tsx +11 -0
  8. package/defaults/app-with-backend/src/components/PersonOverview.module.css +100 -0
  9. package/defaults/app-with-backend/src/components/PersonOverview.tsx +97 -0
  10. package/defaults/app-with-backend/src/components/PersonOverviewContext.tsx +12 -0
  11. package/defaults/app-with-backend/src/components/PersonPreview.module.css +97 -0
  12. package/defaults/app-with-backend/src/components/PersonPreview.tsx +107 -0
  13. package/defaults/app-with-backend/src/components/Spinner.module.css +17 -0
  14. package/defaults/app-with-backend/src/components/Spinner.tsx +12 -0
  15. package/defaults/app-with-backend/src/index.tsx +34 -0
  16. package/defaults/app-with-backend/src/layout/DefaultLayout.module.css +15 -0
  17. package/defaults/app-with-backend/src/layout/DefaultLayout.tsx +12 -0
  18. package/defaults/app-with-backend/src/layout/Header.module.css +46 -0
  19. package/defaults/app-with-backend/src/layout/Header.tsx +27 -0
  20. package/defaults/app-with-backend/src/linked.frontend.datasets.json +12 -0
  21. package/defaults/app-with-backend/src/linked.frontend.storage.ts +36 -0
  22. package/defaults/app-with-backend/src/package.ts +12 -0
  23. package/defaults/app-with-backend/src/pages/Home.module.css +23 -0
  24. package/defaults/app-with-backend/src/pages/Home.tsx +14 -0
  25. package/defaults/app-with-backend/src/pages/Page1.module.css +18 -0
  26. package/defaults/app-with-backend/src/pages/Page1.tsx +16 -0
  27. package/defaults/app-with-backend/src/pages/PageNotFound.module.css +19 -0
  28. package/defaults/app-with-backend/src/pages/PageNotFound.tsx +14 -0
  29. package/defaults/app-with-backend/src/pages/Signin.module.css +20 -0
  30. package/defaults/app-with-backend/src/pages/Signin.tsx +16 -0
  31. package/defaults/app-with-backend/src/routes.tsx +85 -0
  32. package/defaults/app-with-backend/src/theme.css +91 -0
  33. package/defaults/app-with-backend/src/types.ts +14 -0
  34. package/defaults/app-with-backend/src/utils/lazyWithPreload.tsx +42 -0
  35. package/defaults/app-with-backend/src/utils/preloadRoutes.ts +38 -0
  36. package/defaults/app-with-backend/tsconfig.json +24 -0
  37. package/lib/cjs/cli-deprecated.d.ts +2 -0
  38. package/lib/cjs/cli-methods.d.ts +67 -0
  39. package/lib/cjs/cli.d.ts +3 -0
  40. package/lib/cjs/commands/build-package.d.ts +9 -0
  41. package/lib/cjs/commands/safe-yarn.d.ts +10 -0
  42. package/lib/cjs/commands/setup-publish.d.ts +23 -0
  43. package/lib/cjs/config-webpack-app.d.ts +112 -0
  44. package/lib/cjs/config-webpack.d.ts +55 -0
  45. package/lib/cjs/defineConfig.d.ts +8 -0
  46. package/lib/cjs/index.d.ts +12 -0
  47. package/lib/cjs/interfaces.d.ts +131 -0
  48. package/lib/cjs/loaders/register-css-only.d.ts +2 -0
  49. package/lib/cjs/loaders/register.d.ts +2 -0
  50. package/lib/cjs/metadata.d.ts +1 -0
  51. package/lib/cjs/package.json +1 -1
  52. package/lib/cjs/plugins/check-imports.d.ts +2 -0
  53. package/lib/cjs/plugins/declaration-plugin.d.ts +15 -0
  54. package/lib/cjs/plugins/externalise-modules.d.ts +7 -0
  55. package/lib/cjs/plugins/lincd-tailwind-sources.d.ts +3 -0
  56. package/lib/cjs/plugins/watch-run.d.ts +5 -0
  57. package/lib/cjs/tailwind.config.d.ts +8 -0
  58. package/lib/cjs/utils.d.ts +77 -0
  59. package/lib/esm/cli-deprecated.d.ts +2 -0
  60. package/lib/esm/cli-methods.d.ts +67 -0
  61. package/lib/esm/cli.d.ts +3 -0
  62. package/lib/esm/commands/build-package.d.ts +9 -0
  63. package/lib/esm/commands/safe-yarn.d.ts +10 -0
  64. package/lib/esm/commands/setup-publish.d.ts +23 -0
  65. package/lib/esm/config-webpack-app.d.ts +112 -0
  66. package/lib/esm/config-webpack.d.ts +55 -0
  67. package/lib/esm/defineConfig.d.ts +8 -0
  68. package/lib/esm/index.d.ts +12 -0
  69. package/lib/esm/interfaces.d.ts +131 -0
  70. package/lib/esm/launch.d.ts +3 -0
  71. package/lib/esm/loaders/register-css-only.d.ts +2 -0
  72. package/lib/esm/loaders/register.d.ts +2 -0
  73. package/lib/esm/metadata.d.ts +1 -0
  74. package/lib/esm/package.json +1 -1
  75. package/lib/esm/plugins/check-imports.d.ts +2 -0
  76. package/lib/esm/plugins/declaration-plugin.d.ts +15 -0
  77. package/lib/esm/plugins/externalise-modules.d.ts +7 -0
  78. package/lib/esm/plugins/lincd-tailwind-sources.d.ts +3 -0
  79. package/lib/esm/plugins/watch-run.d.ts +5 -0
  80. package/lib/esm/tailwind.config.d.ts +8 -0
  81. package/lib/esm/utils.d.ts +77 -0
  82. package/package.json +1 -1
  83. package/tests/e2e/create-app.spec.ts +157 -0
  84. package/tests/unit/safeYarn.test.ts +55 -0
@@ -0,0 +1,112 @@
1
+ import TerserPlugin from 'terser-webpack-plugin';
2
+ import { LinkedConfig } from './interfaces.js';
3
+ /**
4
+ * Converts a css class name to a unique scoped name (for CSS Modules)
5
+ * @param context
6
+ * @param currentFormat
7
+ * @param name
8
+ */
9
+ declare function getLocalIdent(context: any, currentFormat: any, name: any): string;
10
+ export declare const getLinkedConfig: () => Promise<LinkedConfig>;
11
+ export declare const getWebpackAppConfig: () => Promise<{
12
+ mode: string;
13
+ devtool: string;
14
+ entry: string[];
15
+ watch: boolean;
16
+ output: {
17
+ path: string;
18
+ filename: string;
19
+ publicPath: string;
20
+ clean: boolean;
21
+ };
22
+ watchOptions: {
23
+ ignored: RegExp;
24
+ aggregateTimeout: number;
25
+ };
26
+ devServer: {
27
+ client: {
28
+ progress: boolean;
29
+ };
30
+ };
31
+ plugins: any[];
32
+ externals: {
33
+ [npmModule: string]: string;
34
+ };
35
+ module: {
36
+ rules: ({
37
+ test: RegExp;
38
+ use: (string | {
39
+ loader: string;
40
+ options: {
41
+ url: boolean;
42
+ importLoaders: number;
43
+ modules: {
44
+ mode: string;
45
+ getLocalIdent: typeof getLocalIdent;
46
+ auto: (resourcePath: string) => boolean;
47
+ };
48
+ postcssOptions?: undefined;
49
+ };
50
+ } | {
51
+ loader: string;
52
+ options: {
53
+ postcssOptions: {
54
+ plugins: any[];
55
+ };
56
+ url?: undefined;
57
+ importLoaders?: undefined;
58
+ modules?: undefined;
59
+ };
60
+ })[];
61
+ exclude?: undefined;
62
+ } | {
63
+ test: RegExp;
64
+ use: {
65
+ loader: string;
66
+ options: {
67
+ transpileOnly: boolean;
68
+ getCustomTransformers?: () => {
69
+ before: import("typescript").TransformerFactory<import("typescript").SourceFile>[];
70
+ };
71
+ };
72
+ }[];
73
+ exclude: RegExp;
74
+ })[];
75
+ };
76
+ optimization: {
77
+ minimize: boolean;
78
+ minimizer: TerserPlugin<{
79
+ keep_classnames: boolean;
80
+ }>[];
81
+ splitChunks: {
82
+ cacheGroups: {
83
+ defaultVendors: boolean;
84
+ default: boolean;
85
+ };
86
+ };
87
+ };
88
+ stats: {
89
+ chunks: boolean;
90
+ assets: boolean;
91
+ entrypoints: boolean;
92
+ children: boolean;
93
+ modules: boolean;
94
+ };
95
+ resolve: {
96
+ extensions: string[];
97
+ alias: {
98
+ [oldNpmPath: string]: string;
99
+ };
100
+ extensionAlias: {
101
+ '.js': string[];
102
+ '.jsx': string[];
103
+ };
104
+ };
105
+ cache: {
106
+ type: "filesystem";
107
+ } | {
108
+ type: "memory";
109
+ };
110
+ }>;
111
+ export {};
112
+ //# sourceMappingURL=config-webpack-app.d.ts.map
@@ -0,0 +1,55 @@
1
+ import { AdjustedModuleConfig } from './interfaces';
2
+ import TerserPlugin from 'terser-webpack-plugin';
3
+ export declare function generateWebpackConfig(buildName: any, moduleName: any, config?: AdjustedModuleConfig): {
4
+ entry: any;
5
+ output: {
6
+ filename: string;
7
+ path: string;
8
+ devtoolModuleFilenameTemplate: string;
9
+ };
10
+ devtool: string;
11
+ mode: string;
12
+ resolve: {
13
+ extensions: string[];
14
+ alias: {
15
+ [oldNpmPath: string]: string;
16
+ };
17
+ fallback: {
18
+ crypto: boolean;
19
+ };
20
+ };
21
+ resolveLoader: {
22
+ modules: string[];
23
+ };
24
+ optimization: {
25
+ minimize: boolean;
26
+ minimizer: TerserPlugin<import("terser").MinifyOptions>[];
27
+ };
28
+ watch: boolean;
29
+ watchOptions: {
30
+ ignored: string[];
31
+ aggregateTimeout: number;
32
+ };
33
+ module: {
34
+ rules: any[];
35
+ };
36
+ externals: ({ context, request, contextInfo }: {
37
+ context: any;
38
+ request: any;
39
+ contextInfo: any;
40
+ }, callback: any) => any;
41
+ plugins: any[];
42
+ stats: {
43
+ errorDetails: boolean;
44
+ chunks: boolean;
45
+ children: boolean;
46
+ version: boolean;
47
+ hash: boolean;
48
+ entrypoints: boolean;
49
+ modules: boolean;
50
+ };
51
+ cache: {
52
+ type: string;
53
+ };
54
+ };
55
+ //# sourceMappingURL=config-webpack.d.ts.map
@@ -0,0 +1,8 @@
1
+ import { LinkedConfig } from './interfaces';
2
+ /**
3
+ * Define a Linked configuration with type checking and autocomplete
4
+ * @param config The configuration object
5
+ * @returns The same configuration object (identity function for type inference)
6
+ */
7
+ export declare function defineConfig(config: LinkedConfig): LinkedConfig;
8
+ //# sourceMappingURL=defineConfig.d.ts.map
@@ -0,0 +1,12 @@
1
+ export { default as DeclarationPlugin } from './plugins/declaration-plugin';
2
+ export { default as externaliseModules } from './plugins/externalise-modules';
3
+ export { default as tailwindConfig } from './tailwind.config';
4
+ import { generateWebpackConfig } from './config-webpack';
5
+ export { generateWebpackConfig };
6
+ export * from './utils';
7
+ export { defineConfig } from './defineConfig';
8
+ export type { LinkedConfig, LinkedWebpackConfig, LinkedServerConfig, } from './interfaces';
9
+ export { buildPackageByPath } from './commands/build-package';
10
+ export { safeYarn } from './commands/safe-yarn';
11
+ export { setupPublish } from './commands/setup-publish';
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,131 @@
1
+ export interface ModuleConfig {
2
+ internals?: string[] | '*';
3
+ entry?: string;
4
+ filename?: string;
5
+ declarations?: boolean;
6
+ internalsources?: string[];
7
+ externals?: {
8
+ [npmModule: string]: string;
9
+ };
10
+ target?: 'es5' | 'es6';
11
+ es5?: ModuleConfig;
12
+ es6?: ModuleConfig;
13
+ dev?: ModuleConfig;
14
+ prod?: ModuleConfig;
15
+ debug?: boolean;
16
+ alias?: {
17
+ [oldNpmPath: string]: string;
18
+ };
19
+ provide?: {};
20
+ environment?: 'nodejs' | 'browser' | 'polymorphic';
21
+ outputPath?: string;
22
+ bundlePath?: string;
23
+ es5Server?: boolean;
24
+ analyse?: boolean;
25
+ cssIdentName?: string;
26
+ beforeBuildCommand?: string;
27
+ afterBuildCommand?: string;
28
+ afterBuildCommandProduction?: string;
29
+ afterFirstBuildCommand?: string;
30
+ cssGlobalModulePaths?: string;
31
+ cssMode?: 'tailwind' | 'postcss';
32
+ cssFileName?: string;
33
+ tsConfigOverwrites?: Object;
34
+ }
35
+ export interface AdjustedModuleConfig extends ModuleConfig {
36
+ watch?: boolean;
37
+ productionMode?: boolean;
38
+ }
39
+ export interface PackageDetails {
40
+ path: string;
41
+ packageName: string;
42
+ }
43
+ /**
44
+ * Webpack build configuration
45
+ */
46
+ export interface LinkedWebpackConfig {
47
+ /**
48
+ * Enable webpack filesystem caching for faster rebuilds
49
+ * @default true
50
+ */
51
+ cache?: boolean;
52
+ /**
53
+ * Enable webpack bundle analyzer to visualize bundle size
54
+ * @default false
55
+ */
56
+ analyse?: boolean;
57
+ /**
58
+ * Additional webpack plugins
59
+ */
60
+ plugins?: any[];
61
+ /**
62
+ * Webpack externals configuration - modules to exclude from bundle
63
+ */
64
+ externals?: {
65
+ [npmModule: string]: string;
66
+ };
67
+ /**
68
+ * Webpack alias configuration for import path shortcuts
69
+ */
70
+ alias?: {
71
+ [oldNpmPath: string]: string;
72
+ };
73
+ /**
74
+ * Patterns for CSS files that should not use CSS Modules
75
+ */
76
+ cssGlobalModulePaths?: (RegExp | string)[];
77
+ }
78
+ /**
79
+ * Server-specific configuration
80
+ */
81
+ export interface LinkedServerConfig {
82
+ /**
83
+ * Paths to cache for server-side rendering (SSR)
84
+ * Cached pages will be served from memory for faster response times
85
+ */
86
+ cachePaths?: string[];
87
+ /**
88
+ * Cache timeout in milliseconds for SSR rendered pages
89
+ * After this time, cached pages will be re-rendered
90
+ * @default 300000 (5 minutes)
91
+ */
92
+ cacheTimeout?: number;
93
+ /**
94
+ * Enable multi-core server processing (requires @semantu/multicore)
95
+ * Spawns worker processes to handle requests across multiple CPU cores
96
+ * @default false
97
+ */
98
+ multiCore?: boolean;
99
+ /**
100
+ * Function that loads the app component (for SSR and hot reloading)
101
+ * Must be a function to support hot module reloading
102
+ */
103
+ loadAppComponent?: () => any;
104
+ /**
105
+ * Function that loads the app routes configuration (for SSR preloading)
106
+ * Must be a function to support hot module reloading
107
+ * Should return a RoutesModule from 'lincd-server'
108
+ */
109
+ loadRoutes?: () => Promise<any>;
110
+ }
111
+ /**
112
+ * Complete Linked configuration
113
+ */
114
+ export interface LinkedConfig {
115
+ /**
116
+ * CSS processing mode (shared by webpack and server for SSR)
117
+ * - 'tailwind': Use Tailwind CSS v4 with @tailwindcss/postcss. Still supports CSS Modules for .module.css files
118
+ * - 'postcss': Use PostCSS with nesting support and CSS Modules for .module.css files
119
+ * @default 'postcss'
120
+ */
121
+ cssMode?: 'tailwind' | 'postcss';
122
+ /**
123
+ * Webpack build configuration
124
+ */
125
+ webpack?: LinkedWebpackConfig;
126
+ /**
127
+ * Server configuration
128
+ */
129
+ server?: LinkedServerConfig;
130
+ }
131
+ //# sourceMappingURL=interfaces.d.ts.map
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=launch.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=register-css-only.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=register.d.ts.map
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=metadata.d.ts.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@_linked/cli",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "description": "Command line tools for the @_linked/* packages and apps",
5
5
  "typesVersions": {
6
6
  "*": {
@@ -0,0 +1,2 @@
1
+ export default function handler(source: any): any;
2
+ //# sourceMappingURL=check-imports.d.ts.map
@@ -0,0 +1,15 @@
1
+ export default class DeclarationPlugin {
2
+ options: any;
3
+ moduleName: string;
4
+ excludedReferences: string[];
5
+ logMessages: boolean;
6
+ modulePackageInfo: any;
7
+ declarationFiles: any;
8
+ private exportRoot;
9
+ constructor(options?: any);
10
+ apply(compiler: any): void;
11
+ private debug;
12
+ private log;
13
+ private generateCombinedDeclaration;
14
+ }
15
+ //# sourceMappingURL=declaration-plugin.d.ts.map
@@ -0,0 +1,7 @@
1
+ declare var externaliseModules: (config: any, es5: any) => ({ context, request, contextInfo }: {
2
+ context: any;
3
+ request: any;
4
+ contextInfo: any;
5
+ }, callback: any) => any;
6
+ export default externaliseModules;
7
+ //# sourceMappingURL=externalise-modules.d.ts.map
@@ -0,0 +1,3 @@
1
+ declare const getLincdSources: () => string[];
2
+ export default getLincdSources;
3
+ //# sourceMappingURL=lincd-tailwind-sources.d.ts.map
@@ -0,0 +1,5 @@
1
+ declare class WatchRunPlugin {
2
+ apply(compiler: any): void;
3
+ }
4
+ export default WatchRunPlugin;
5
+ //# sourceMappingURL=watch-run.d.ts.map
@@ -0,0 +1,8 @@
1
+ declare const config: {
2
+ content: string[];
3
+ theme: {
4
+ extend: {};
5
+ };
6
+ };
7
+ export default config;
8
+ //# sourceMappingURL=tailwind.config.d.ts.map
@@ -0,0 +1,77 @@
1
+ import { ExecOptions } from 'child_process';
2
+ import { PackageDetails } from './interfaces.js';
3
+ export declare var getFileImports: (filePath: any) => Promise<string[]>;
4
+ /**
5
+ *
6
+ * @param importPath The import path to check
7
+ * @param curFileDepth How many folders deep the current file is (0 = src, 1 = src/foo, etc.)
8
+ * @returns
9
+ */
10
+ export declare var isInvalidLINCDImport: (importPath: string, curFileDepth: number) => boolean;
11
+ export declare var isImportOutsideOfPackage: (importPath: string, curFileDepth: number) => boolean;
12
+ /**
13
+ *
14
+ * @param importPath The import path to check
15
+ * @param curFileDepth How many folders deep the current file is (0 = src, 1 = src/foo, etc.)
16
+ * @returns
17
+ */
18
+ export declare var isValidLINCDImport: (importPath: string, curFileDepth: number) => boolean;
19
+ export declare var isImportWithMissingExtension: (importPath: string) => boolean;
20
+ export declare var getPackageJSON: (root?: string, error?: boolean) => any;
21
+ /**
22
+ * Scans package.json for dependencies that are LINCD packages
23
+ * Also looks into dependencies of dependencies
24
+ * If no packageJson is given, it will attempt to obtain it from the current working directory
25
+ * Returns an array of lincd packages, with each entry containing an array with the package name and the local path to the package
26
+ * @param packageJson
27
+ */
28
+ export declare var getLINCDDependencies: (packageJson?: any, checkedPackages?: Set<string>) => [string, string, string[]][];
29
+ export declare const getLastBuildTime: (packagePath: any) => {
30
+ lastModified: Date;
31
+ lastModifiedName: any;
32
+ lastModifiedTime: number;
33
+ };
34
+ export declare const getLastModifiedSourceTime: (packagePath: any) => {
35
+ lastModified: Date;
36
+ lastModifiedName: any;
37
+ lastModifiedTime: number;
38
+ };
39
+ export declare const getLastCommitTime: (packagePath: any) => Promise<{
40
+ date: Date;
41
+ changes: string;
42
+ commitId: string;
43
+ }>;
44
+ export declare const getLastModifiedFile: (filePath: any, config?: {}) => {
45
+ lastModified: Date;
46
+ lastModifiedName: any;
47
+ lastModifiedTime: number;
48
+ };
49
+ export declare var getModulePackageJSON: (module_name: any, work_dir?: any) => any;
50
+ export declare function execp(cmd: any, log?: boolean, allowError?: boolean, options?: any): Promise<null>;
51
+ export declare function execPromise(command: any, log?: boolean, allowError?: boolean, options?: ExecOptions, pipeOutput?: boolean): Promise<string>;
52
+ export declare function generateScopedNameProduction(cssClassName: any, filepath: any, css?: any): string;
53
+ export declare function generateScopedName(cssClassName: any, filepath: any, css?: any): string;
54
+ export declare const needsRebuilding: (pkg: PackageDetails, useGitForLastModified: boolean, log?: boolean) => Promise<boolean>;
55
+ export declare function log(...messages: any[]): void;
56
+ export declare function debug(config: any, ...messages: any[]): void;
57
+ export declare function debugInfo(...messages: any[]): void;
58
+ export declare function warn(...messages: any[]): void;
59
+ export declare function flatten(arr: any): any;
60
+ export declare function getLinkedTailwindColors(): {
61
+ 'primary-color': string;
62
+ 'font-color': string;
63
+ };
64
+ /**
65
+ * Recursively get all files in a directory
66
+ * https://stackoverflow.com/a/45130990/831465
67
+ * @param dir The directory to get files from
68
+ * @returns A promise that resolves to an array of file paths
69
+ */
70
+ export declare function getFiles(dir: string, filenameFilter?: string): Promise<string[]>;
71
+ /**
72
+ * Strip ANSI escape codes from a string
73
+ * @param str The string to strip ANSI codes from
74
+ * @returns The string with ANSI codes removed
75
+ */
76
+ export declare function stripAnsi(str: string): string;
77
+ //# sourceMappingURL=utils.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@_linked/cli",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "description": "Command line tools for the @_linked/* packages and apps",
5
5
  "main": "lib/cjs/index.js",
6
6
  "module": "lib/esm/index.js",
@@ -0,0 +1,157 @@
1
+ // End-to-end: scaffold a Linked app via `linked create-app`, install, start it
2
+ // against a fresh Fuseki container, then drive the Person CRUD flow.
3
+ //
4
+ // Self-contained: scaffolds into an OS tmpdir, installs deps from npm,
5
+ // starts a Fuseki testcontainer, exercises the Person fixture. No
6
+ // dependency on any parent monorepo. Companion `@_linked/*` packages
7
+ // must be published to npm at compatible versions; if they aren't yet,
8
+ // set SKIP_E2E=1 to skip this test.
9
+ import {test, expect} from '@playwright/test';
10
+ import {GenericContainer, Wait, StartedTestContainer} from 'testcontainers';
11
+ import {spawn, ChildProcess, execSync} from 'node:child_process';
12
+ import {rmSync, existsSync, mkdtempSync} from 'node:fs';
13
+ import {tmpdir} from 'node:os';
14
+ import {dirname, join, resolve} from 'node:path';
15
+ import {fileURLToPath} from 'node:url';
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const CLI_LAUNCH = resolve(__dirname, '../../lib/esm/launch.js');
19
+
20
+ // Deterministic-enough app name; salted so concurrent runs don't collide.
21
+ const APP_NAME = `e2e-test-app-${process.pid}`;
22
+ const SCAFFOLD_ROOT = mkdtempSync(join(tmpdir(), 'linked-cli-e2e-'));
23
+ const APP_DIR = join(SCAFFOLD_ROOT, APP_NAME);
24
+ const DATASET = `${APP_NAME}-main`;
25
+ const APP_PORT = 4400 + (process.pid % 100); // avoid collision with a CN backend on 4040
26
+
27
+ let fuseki: StartedTestContainer;
28
+ let appProc: ChildProcess | null = null;
29
+ let fusekiUrl: string;
30
+
31
+ test.beforeAll(async () => {
32
+ if (process.env.SKIP_E2E === '1') test.skip();
33
+
34
+ // 1. Start a fresh Fuseki container with a random host port.
35
+ fuseki = await new GenericContainer('stain/jena-fuseki')
36
+ .withExposedPorts(3030)
37
+ .withEnvironment({ADMIN_PASSWORD: 'admin'})
38
+ .withWaitStrategy(Wait.forLogMessage(/Started.*Server/))
39
+ .start();
40
+ fusekiUrl = `http://${fuseki.getHost()}:${fuseki.getMappedPort(3030)}`;
41
+
42
+ // 2. Scaffold the app via the local CLI build.
43
+ if (existsSync(APP_DIR)) rmSync(APP_DIR, {recursive: true, force: true});
44
+ execSync(
45
+ `node ${CLI_LAUNCH} create-app ${APP_NAME} --app-name "E2E" --app-prefix e2e --app-domain e2e.local --skip-install`,
46
+ {cwd: SCAFFOLD_ROOT, stdio: 'inherit'},
47
+ );
48
+
49
+ // 3. Create the matching dataset in Fuseki. Template default is
50
+ // `${hyphen_name}-main` → matches DATASET above.
51
+ const adminAuth = Buffer.from('admin:admin').toString('base64');
52
+ const resp = await fetch(`${fusekiUrl}/$/datasets`, {
53
+ method: 'POST',
54
+ headers: {
55
+ 'Content-Type': 'application/x-www-form-urlencoded',
56
+ Authorization: `Basic ${adminAuth}`,
57
+ },
58
+ body: `dbName=${DATASET}&dbType=mem`,
59
+ });
60
+ if (!resp.ok && resp.status !== 409) {
61
+ throw new Error(`Failed to create dataset: ${resp.status} ${await resp.text()}`);
62
+ }
63
+
64
+ // 4. Install scaffolded app deps from npm. The scaffolded package.json
65
+ // pins @_linked/* versions; install resolves them straight from the
66
+ // registry (no workspace symlinks involved).
67
+ execSync('yarn install', {cwd: APP_DIR, stdio: 'inherit'});
68
+
69
+ // 5. Boot the app with env overrides pointing at the testcontainer Fuseki.
70
+ appProc = spawn('yarn', ['start'], {
71
+ cwd: APP_DIR,
72
+ env: {
73
+ ...process.env,
74
+ FUSEKI_BASE_URL: fusekiUrl,
75
+ FUSEKI_DATASET: DATASET,
76
+ PORT: String(APP_PORT),
77
+ },
78
+ stdio: 'inherit',
79
+ });
80
+
81
+ // 6. Wait for HTTP 200 on /.
82
+ await waitForServer(`http://localhost:${APP_PORT}/`, 90_000);
83
+ }, /* timeout */ 4 * 60 * 1000);
84
+
85
+ test.afterAll(async () => {
86
+ // Kill the app server.
87
+ if (appProc) {
88
+ appProc.kill('SIGTERM');
89
+ await new Promise((r) => setTimeout(r, 1000));
90
+ if (!appProc.killed) appProc.kill('SIGKILL');
91
+ }
92
+
93
+ // Drop the Fuseki dataset (idempotent).
94
+ try {
95
+ const adminAuth = Buffer.from('admin:admin').toString('base64');
96
+ await fetch(`${fusekiUrl}/$/datasets/${DATASET}`, {
97
+ method: 'DELETE',
98
+ headers: {Authorization: `Basic ${adminAuth}`},
99
+ });
100
+ } catch {
101
+ // Container may be gone already; ignore.
102
+ }
103
+
104
+ // Stop testcontainer.
105
+ await fuseki?.stop().catch(() => {});
106
+
107
+ // Remove scaffolded app folder + its parent tmpdir.
108
+ if (existsSync(SCAFFOLD_ROOT)) {
109
+ rmSync(SCAFFOLD_ROOT, {recursive: true, force: true});
110
+ }
111
+ }, /* timeout */ 2 * 60 * 1000);
112
+
113
+ test('Person fixture: add → edit → delete round-trip', async ({page}) => {
114
+ await page.goto(`http://localhost:${APP_PORT}/`);
115
+ await expect(page.getByTestId('person-overview')).toBeVisible();
116
+
117
+ // Add Alice
118
+ await page.getByPlaceholder('First name').fill('Alice');
119
+ await page.getByPlaceholder('Last name').fill('Anderson');
120
+ await page.getByRole('button', {name: 'Add'}).click();
121
+ await expect(page.getByTestId('person-name').filter({hasText: 'Alice Anderson'})).toBeVisible();
122
+
123
+ // Edit to Alicia
124
+ await page.getByRole('button', {name: 'Edit'}).first().click();
125
+ const editRow = page.getByTestId('person-edit').first();
126
+ await editRow.getByLabel('First name').fill('Alicia');
127
+ await editRow.getByRole('button', {name: 'Save'}).click();
128
+ await expect(
129
+ page.getByTestId('person-name').filter({hasText: 'Alicia Anderson'}),
130
+ ).toBeVisible();
131
+
132
+ // Reload — change must persist
133
+ await page.reload();
134
+ await expect(
135
+ page.getByTestId('person-name').filter({hasText: 'Alicia Anderson'}),
136
+ ).toBeVisible();
137
+
138
+ // Delete — row disappears
139
+ await page.getByRole('button', {name: 'Delete'}).first().click();
140
+ await expect(
141
+ page.getByTestId('person-name').filter({hasText: 'Alicia Anderson'}),
142
+ ).toBeHidden();
143
+ });
144
+
145
+ async function waitForServer(url: string, timeoutMs: number): Promise<void> {
146
+ const deadline = Date.now() + timeoutMs;
147
+ while (Date.now() < deadline) {
148
+ try {
149
+ const r = await fetch(url, {method: 'GET'});
150
+ if (r.ok) return;
151
+ } catch {
152
+ /* not ready yet */
153
+ }
154
+ await new Promise((res) => setTimeout(res, 1000));
155
+ }
156
+ throw new Error(`Server at ${url} did not respond within ${timeoutMs}ms`);
157
+ }