|
1 | | -import { ux } from '@oclif/core'; |
2 | | -import { |
3 | | - CloudProject, |
4 | | - parseYamlFile, |
5 | | - SelfHostedProject, |
6 | | - SERVICE_FILENAME, |
7 | | - SYNC_FILENAME, |
8 | | - SyncDiagnostic, |
9 | | - SyncValidationTestRunResult, |
10 | | - validateCloudSyncRules, |
11 | | - validateSelfHostedSyncRules, |
12 | | - ValidationResult, |
13 | | - ValidationTestResult, |
14 | | - ValidationTestRunResult |
15 | | -} from '@powersync/cli-core'; |
16 | | -import { ServiceCloudConfig, ServiceSelfHostedConfig } from '@powersync/cli-schemas'; |
17 | | -import { existsSync, readFileSync } from 'node:fs'; |
18 | | -import { join } from 'node:path'; |
19 | | -import { Document } from 'yaml'; |
20 | | - |
21 | | -/** Indentation used for nested human-readable output rows. */ |
22 | | -export const INDENT = ' '; |
23 | | - |
24 | | -/** Bullet character used for list rows in human-readable output. */ |
25 | | -export const BULLET = '•'; |
| 1 | +import { ValidationTestRunResult } from '@powersync/cli-core'; |
26 | 2 |
|
27 | 3 | /** |
28 | 4 | * Definition of a test: display name and async runner function. |
29 | 5 | */ |
30 | 6 | export type ValidationTestDefinition = { |
31 | | - name: string; |
| 7 | + name: ValidationTest; |
32 | 8 | run: () => Promise<ValidationTestRunResult>; |
33 | 9 | }; |
34 | 10 |
|
35 | 11 | /** |
36 | 12 | * Runtime test entry, storing the in-flight promise and optional settled result. |
37 | 13 | */ |
38 | | -export type ValidationTestEntry = { |
| 14 | +export type ValidationTestResultEntry = { |
39 | 15 | name: string; |
40 | | - promise: Promise<ValidationTestRunResult>; |
41 | 16 | result?: ValidationTestRunResult; |
42 | 17 | }; |
43 | 18 |
|
44 | 19 | /** |
45 | 20 | * Named test buckets used by the validate command. |
46 | 21 | */ |
47 | 22 | export enum ValidationTest { |
48 | | - 'CONFIGURATION-SCHEMA' = 'Validate Configuration Schema', |
49 | | - 'CONNECTIONS' = 'Test Connections', |
50 | | - 'SYNC-CONFIG' = 'Validate Sync Config' |
51 | | -} |
52 | | - |
53 | | -/** |
54 | | - * Formats spinner text showing per-test progress while tests are running. |
55 | | - * These logs are indeted with bullets for readability, and update in-place as each test settles to show pass/fail status. |
56 | | - */ |
57 | | -export function formatOraMessage(entries: ValidationTestEntry[]): string { |
58 | | - return entries |
59 | | - .map((e) => (e.result === undefined ? `\t... ${e.name}` : e.result.passed ? `\t✓ ${e.name}` : `\t✗ ${e.name}`)) |
60 | | - .join('\n'); |
61 | | -} |
62 | | - |
63 | | -/** |
64 | | - * Formats one test result into human-readable plain text. |
65 | | - */ |
66 | | -function formatTestResultHuman(test: ValidationTestResult): string { |
67 | | - const status = test.passed ? '✓' : '✗'; |
68 | | - const name = `${status} ${test.name}`; |
69 | | - const warningLines = (test.warnings ?? []).map((warning) => `${INDENT}${BULLET} [warning] ${warning}`); |
70 | | - if (test.passed && warningLines.length === 0) return name; |
71 | | - const errorLines = (test.errors ?? []).map((e) => `${INDENT}${BULLET} ${e}`); |
72 | | - return [name, ...warningLines, ...errorLines].join('\n'); |
73 | | -} |
74 | | - |
75 | | -/** |
76 | | - * Formats suite output for `--output=human`. |
77 | | - */ |
78 | | -export function formatValidationHuman(result: ValidationResult): string { |
79 | | - const header = result.passed ? 'All validation tests passed.' : 'Some validation tests failed.'; |
80 | | - const lines = [header, '', ...result.tests.map((test) => formatTestResultHuman(test))]; |
81 | | - return lines.join('\n'); |
82 | | -} |
83 | | - |
84 | | -/** |
85 | | - * Formats suite output for `--output=json`. |
86 | | - */ |
87 | | -export function formatValidationJson(result: ValidationResult): string { |
88 | | - return JSON.stringify(result, null, 2); |
89 | | -} |
90 | | - |
91 | | -/** |
92 | | - * Formats suite output for `--output=yaml`. |
93 | | - */ |
94 | | -export function formatValidationYaml(result: ValidationResult): string { |
95 | | - return new Document(result).toString(); |
96 | | -} |
97 | | - |
98 | | -/** |
99 | | - * Builds a two-line diagnostic message containing a source fragment and location-prefixed message. |
100 | | - */ |
101 | | -function formatSyncDiagnosticMessage(diagnostic: SyncDiagnostic, syncRulesContent: string): string { |
102 | | - const lineText = getLineAt(syncRulesContent, diagnostic.startLine); |
103 | | - const fragment = getLineFragment(lineText, diagnostic.startColumn); |
104 | | - |
105 | | - return `${fragment}\n${diagnostic.startLine}:${diagnostic.startColumn} ${diagnostic.message}`; |
106 | | -} |
107 | | - |
108 | | -/** |
109 | | - * Retrieves a specific 1-based line from text content. |
110 | | - */ |
111 | | -function getLineAt(content: string, lineNumber: number): string { |
112 | | - if (!content) { |
113 | | - return ''; |
114 | | - } |
115 | | - |
116 | | - const lines = content.split(/\r?\n/); |
117 | | - return lines[Math.max(0, lineNumber - 1)] ?? ''; |
118 | | -} |
119 | | - |
120 | | -/** |
121 | | - * Extracts a nearby fragment around `startColumn`, clipping to a fixed width for readability. |
122 | | - */ |
123 | | -function getLineFragment(lineText: string, startColumn: number): string { |
124 | | - if (!lineText) { |
125 | | - return '(line unavailable)'; |
126 | | - } |
127 | | - |
128 | | - const maxWidth = 120; |
129 | | - if (lineText.length <= maxWidth) { |
130 | | - return lineText; |
131 | | - } |
132 | | - |
133 | | - const centerIndex = Math.max(0, startColumn - 1); |
134 | | - let start = Math.max(0, centerIndex - Math.floor(maxWidth / 2)); |
135 | | - const end = Math.min(lineText.length, start + maxWidth); |
136 | | - |
137 | | - if (end - start < maxWidth) { |
138 | | - start = Math.max(0, end - maxWidth); |
139 | | - } |
140 | | - |
141 | | - const prefix = start > 0 ? '…' : ''; |
142 | | - const suffix = end < lineText.length ? '…' : ''; |
143 | | - |
144 | | - return `${prefix}${lineText.slice(start, end)}${suffix}`; |
145 | | -} |
146 | | - |
147 | | -/** |
148 | | - * Renders a warning into two lines for human output: |
149 | | - * 1) gray source fragment |
150 | | - * 2) yellow `[warning] line:column` prefix followed by plain message text |
151 | | - */ |
152 | | -export function renderWarningForHumanOutput(warning: string): string[] { |
153 | | - const [fragmentRaw, locationAndMessageRaw] = warning.split('\n', 2); |
154 | | - const fragment = fragmentRaw ?? ''; |
155 | | - const locationAndMessage = locationAndMessageRaw ?? ''; |
156 | | - |
157 | | - const parsed = locationAndMessage.match(/^(\d+:\d+)\s+([\s\S]+)$/); |
158 | | - const location = parsed?.[1] ?? ''; |
159 | | - const message = parsed?.[2] ?? locationAndMessage; |
160 | | - |
161 | | - const lines = [ux.colorize('gray', `${INDENT}${BULLET} ${fragment}`)]; |
162 | | - |
163 | | - if (location) { |
164 | | - lines.push(`${INDENT}${ux.colorize('yellow', '[warning]')} ${ux.colorize('yellow', location)} ${message}`); |
165 | | - } else { |
166 | | - lines.push(`${INDENT}${ux.colorize('yellow', '[warning]')} ${message}`); |
167 | | - } |
168 | | - |
169 | | - return lines; |
170 | | -} |
171 | | - |
172 | | -/** |
173 | | - * Validates `service.yaml` against the cloud or self-hosted schema, depending on project type. |
174 | | - */ |
175 | | -export async function runConfigTest(projectDir: string, isCloud: boolean): Promise<ValidationTestRunResult> { |
176 | | - const servicePath = join(projectDir, SERVICE_FILENAME); |
177 | | - try { |
178 | | - const doc = parseYamlFile(servicePath); |
179 | | - const raw = doc.contents?.toJSON(); |
180 | | - if (isCloud) { |
181 | | - ServiceCloudConfig.decode(raw); |
182 | | - } else { |
183 | | - ServiceSelfHostedConfig.decode(raw); |
184 | | - } |
185 | | - |
186 | | - return { passed: true }; |
187 | | - } catch (error) { |
188 | | - const message = error instanceof Error ? error.message : String(error); |
189 | | - return { errors: [message], passed: false }; |
190 | | - } |
191 | | -} |
192 | | - |
193 | | -/** |
194 | | - * Runs cloud sync-rules validation and maps diagnostics into warning/error message arrays. |
195 | | - */ |
196 | | -export async function runSyncConfigTestCloud(project: CloudProject): Promise<SyncValidationTestRunResult> { |
197 | | - const syncRulesPath = join(project.projectDirectory, SYNC_FILENAME); |
198 | | - const syncRulesContent = |
199 | | - project.syncRulesContent ?? (existsSync(syncRulesPath) ? readFileSync(syncRulesPath, 'utf8') : undefined); |
200 | | - const syncText = syncRulesContent ?? ''; |
201 | | - |
202 | | - try { |
203 | | - const result = await validateCloudSyncRules({ |
204 | | - linked: project.linked, |
205 | | - syncRulesContent: syncText |
206 | | - }); |
207 | | - |
208 | | - const errors = result.diagnostics |
209 | | - .filter((diagnostic) => diagnostic.level === 'fatal') |
210 | | - .map((diagnostic) => formatSyncDiagnosticMessage(diagnostic, syncText)); |
211 | | - const warnings = result.diagnostics |
212 | | - .filter((diagnostic) => diagnostic.level === 'warning') |
213 | | - .map((diagnostic) => formatSyncDiagnosticMessage(diagnostic, syncText)); |
214 | | - |
215 | | - return { |
216 | | - // Add detailed diagnostics for errors and warnings. |
217 | | - diagnostics: result.diagnostics, |
218 | | - errors: errors.length > 0 ? errors : undefined, |
219 | | - passed: errors.length === 0, |
220 | | - warnings: warnings.length > 0 ? warnings : undefined |
221 | | - }; |
222 | | - } catch (error) { |
223 | | - const message = error instanceof Error ? error.message : String(error); |
224 | | - return { diagnostics: [], errors: [message], passed: false }; |
225 | | - } |
226 | | -} |
227 | | - |
228 | | -/** |
229 | | - * Runs self-hosted sync-rules validation and maps diagnostics into warning/error message arrays. |
230 | | - */ |
231 | | -export async function runSyncConfigTestSelfHosted(project: SelfHostedProject): Promise<SyncValidationTestRunResult> { |
232 | | - const syncRulesPath = join(project.projectDirectory, SYNC_FILENAME); |
233 | | - const syncRulesContent = existsSync(syncRulesPath) ? readFileSync(syncRulesPath, 'utf8') : undefined; |
234 | | - const syncText = syncRulesContent ?? ''; |
235 | | - try { |
236 | | - const result = await validateSelfHostedSyncRules({ |
237 | | - linked: project.linked, |
238 | | - syncRulesContent: syncText |
239 | | - }); |
240 | | - |
241 | | - const errors = result.diagnostics |
242 | | - .filter((diagnostic) => diagnostic.level === 'fatal') |
243 | | - .map((diagnostic) => formatSyncDiagnosticMessage(diagnostic, syncText)); |
244 | | - const warnings = result.diagnostics |
245 | | - .filter((diagnostic) => diagnostic.level === 'warning') |
246 | | - .map((diagnostic) => formatSyncDiagnosticMessage(diagnostic, syncText)); |
247 | | - |
248 | | - return { |
249 | | - diagnostics: result.diagnostics, |
250 | | - errors: errors.length > 0 ? errors : undefined, |
251 | | - passed: errors.length === 0, |
252 | | - warnings: warnings.length > 0 ? warnings : undefined |
253 | | - }; |
254 | | - } catch (error) { |
255 | | - const message = error instanceof Error ? error.message : String(error); |
256 | | - return { diagnostics: [], errors: [message], passed: false }; |
257 | | - } |
| 23 | + 'CONFIGURATION' = 'configuration', |
| 24 | + 'CONNECTIONS' = 'connections', |
| 25 | + 'SYNC-CONFIG' = 'sync-config' |
258 | 26 | } |
0 commit comments