Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 

307 rader
7.2 KiB

  1. import { request } from '@playwright/test';
  2. import { spawn } from 'node:child_process';
  3. import { access, open, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
  4. import { setTimeout as delay } from 'node:timers/promises';
  5. import {
  6. adminPassword,
  7. adminStorageStatePath,
  8. adminUsername,
  9. authDir,
  10. clusterRunDir,
  11. cnBaseURL,
  12. metadataPath,
  13. ovBaseURL,
  14. playwrightArtifactRoot,
  15. readyMarkerPath,
  16. repoRoot,
  17. rootPassword,
  18. rootStorageStatePath,
  19. rootUsername,
  20. serverBinaryDir,
  21. serverBinaryPath,
  22. serverLogPath,
  23. webRoot,
  24. } from './fixtures/cluster';
  25. type LoginResponse = {
  26. success: boolean;
  27. message?: string;
  28. data?: {
  29. id: number;
  30. username: string;
  31. display_name: string;
  32. role: number;
  33. status: number;
  34. group: string;
  35. };
  36. };
  37. type SimpleResponse = {
  38. success: boolean;
  39. message?: string;
  40. error?: string;
  41. };
  42. async function runCommand(command: string, args: string[], cwd: string) {
  43. await new Promise<void>((resolve, reject) => {
  44. const child = spawn(command, args, {
  45. cwd,
  46. stdio: ['ignore', 'pipe', 'pipe'],
  47. shell: false,
  48. });
  49. let stdout = '';
  50. let stderr = '';
  51. child.stdout.on('data', (chunk) => {
  52. stdout += chunk.toString();
  53. });
  54. child.stderr.on('data', (chunk) => {
  55. stderr += chunk.toString();
  56. });
  57. child.on('error', reject);
  58. child.on('close', (code) => {
  59. if (code === 0) {
  60. resolve();
  61. return;
  62. }
  63. reject(
  64. new Error(
  65. `${command} ${args.join(' ')} failed with code ${code}\n${stdout}${stderr}`,
  66. ),
  67. );
  68. });
  69. });
  70. }
  71. async function killPreviousServer() {
  72. try {
  73. const raw = await readFile(metadataPath, 'utf8');
  74. const metadata = JSON.parse(raw) as { serverPid?: number };
  75. if (!metadata.serverPid) {
  76. return;
  77. }
  78. await killProcessTree(metadata.serverPid);
  79. } catch {
  80. // ignore stale or missing metadata
  81. }
  82. }
  83. async function killProcessTree(pid: number) {
  84. if (process.platform === 'win32') {
  85. await runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], repoRoot);
  86. return;
  87. }
  88. process.kill(pid, 'SIGTERM');
  89. }
  90. async function cleanupListenerPorts() {
  91. if (process.platform !== 'win32') {
  92. return;
  93. }
  94. await runCommand(
  95. 'powershell',
  96. [
  97. '-NoProfile',
  98. '-Command',
  99. "$ports = 3100,3101; " +
  100. "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | " +
  101. "Where-Object { $ports -contains $_.LocalPort } | " +
  102. 'Select-Object -ExpandProperty OwningProcess -Unique | ' +
  103. 'ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }',
  104. ],
  105. repoRoot,
  106. );
  107. }
  108. async function waitForServerReady() {
  109. const deadline = Date.now() + 180_000;
  110. while (Date.now() < deadline) {
  111. try {
  112. await access(readyMarkerPath);
  113. const response = await fetch(`${cnBaseURL}/api/status`, {
  114. headers: { 'Cache-Control': 'no-store' },
  115. });
  116. if (response.ok) {
  117. return;
  118. }
  119. } catch {
  120. // retry
  121. }
  122. await delay(500);
  123. }
  124. throw new Error(
  125. `user migration UI server did not become ready in time, see ${serverLogPath}`,
  126. );
  127. }
  128. async function login(username: string, password: string) {
  129. const api = await request.newContext({
  130. baseURL: cnBaseURL,
  131. extraHTTPHeaders: {
  132. 'Cache-Control': 'no-store',
  133. },
  134. });
  135. const response = await api.post('/api/user/login', {
  136. data: {
  137. username,
  138. password,
  139. },
  140. });
  141. if (!response.ok()) {
  142. throw new Error(`login request for ${username} failed: ${response.status()}`);
  143. }
  144. const payload = (await response.json()) as LoginResponse;
  145. if (!payload.success || !payload.data) {
  146. throw new Error(`login failed for ${username}: ${payload.message ?? 'unknown error'}`);
  147. }
  148. return { api, user: payload.data };
  149. }
  150. async function loginWithRetry(
  151. username: string,
  152. password: string,
  153. timeoutMs = 60_000,
  154. ) {
  155. const deadline = Date.now() + timeoutMs;
  156. let lastError = `login failed for ${username}`;
  157. while (Date.now() < deadline) {
  158. try {
  159. return await login(username, password);
  160. } catch (error) {
  161. lastError = error instanceof Error ? error.message : String(error);
  162. await delay(500);
  163. }
  164. }
  165. throw new Error(lastError);
  166. }
  167. async function writeStorageState(
  168. api: Awaited<ReturnType<typeof request.newContext>>,
  169. user: LoginResponse['data'],
  170. outputPath: string,
  171. ) {
  172. const state = await api.storageState();
  173. await writeFile(
  174. outputPath,
  175. JSON.stringify(
  176. {
  177. cookies: state.cookies,
  178. origins: [
  179. {
  180. origin: cnBaseURL,
  181. localStorage: [
  182. {
  183. name: 'user',
  184. value: JSON.stringify(user),
  185. },
  186. ],
  187. },
  188. ],
  189. },
  190. null,
  191. 2,
  192. ),
  193. 'utf8',
  194. );
  195. }
  196. export default async function globalSetup() {
  197. await killPreviousServer();
  198. await cleanupListenerPorts();
  199. await rm(playwrightArtifactRoot, { recursive: true, force: true });
  200. await mkdir(authDir, { recursive: true });
  201. await mkdir(serverBinaryDir, { recursive: true });
  202. await runCommand('bun', ['run', 'build'], webRoot);
  203. await runCommand(
  204. 'go',
  205. ['build', '-o', serverBinaryPath, './test/e2e/cmd/user_migration_ui_server'],
  206. repoRoot,
  207. );
  208. const logFile = await open(serverLogPath, 'a');
  209. const child = spawn(serverBinaryPath, [], {
  210. cwd: repoRoot,
  211. stdio: ['ignore', logFile.fd, logFile.fd],
  212. });
  213. await logFile.close();
  214. await writeFile(
  215. metadataPath,
  216. JSON.stringify(
  217. {
  218. serverPid: child.pid,
  219. serverBinaryPath,
  220. serverLogPath,
  221. runDir: clusterRunDir,
  222. baseURL: cnBaseURL,
  223. ovBaseURL,
  224. },
  225. null,
  226. 2,
  227. ),
  228. 'utf8',
  229. );
  230. await waitForServerReady();
  231. const rootSession = await loginWithRetry(rootUsername, rootPassword);
  232. try {
  233. const createAdminResponse = await rootSession.api.post('/api/user/', {
  234. headers: {
  235. 'New-API-User': String(rootSession.user.id),
  236. },
  237. data: {
  238. username: adminUsername,
  239. password: adminPassword,
  240. display_name: 'Playwright Admin',
  241. role: 10,
  242. },
  243. });
  244. if (!createAdminResponse.ok()) {
  245. throw new Error(
  246. `create admin request failed: ${createAdminResponse.status()} ${createAdminResponse.statusText()}`,
  247. );
  248. }
  249. const createAdminPayload = (await createAdminResponse.json()) as SimpleResponse;
  250. const createAdminMessage =
  251. createAdminPayload.message ?? createAdminPayload.error ?? 'unknown error';
  252. const adminAlreadyExists =
  253. createAdminMessage.includes('UNIQUE constraint failed: users.username') ||
  254. createAdminMessage.includes('Duplicate entry');
  255. if (!createAdminPayload.success && !adminAlreadyExists) {
  256. throw new Error(
  257. `create admin failed: ${createAdminMessage}`,
  258. );
  259. }
  260. await writeStorageState(
  261. rootSession.api,
  262. rootSession.user,
  263. rootStorageStatePath,
  264. );
  265. } finally {
  266. await rootSession.api.dispose();
  267. }
  268. const adminSession = await loginWithRetry(adminUsername, adminPassword, 20_000);
  269. try {
  270. await writeStorageState(
  271. adminSession.api,
  272. adminSession.user,
  273. adminStorageStatePath,
  274. );
  275. } finally {
  276. await adminSession.api.dispose();
  277. }
  278. }