@simplysm/sd-cli 13.0.71 → 13.0.74
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/README.md +62 -14
- package/dist/commands/init.d.ts +4 -5
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +26 -8
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/publish.js +1 -1
- package/dist/commands/publish.js.map +1 -1
- package/dist/sd-cli-entry.d.ts.map +1 -1
- package/dist/sd-cli-entry.js +0 -20
- package/dist/sd-cli-entry.js.map +1 -1
- package/package.json +4 -4
- package/src/commands/init.ts +40 -21
- package/src/commands/publish.ts +1 -1
- package/src/sd-cli-entry.ts +0 -24
- package/src/utils/replace-deps.ts +361 -361
- package/src/utils/sd-config.ts +44 -44
- package/src/utils/tailwind-config-deps.ts +98 -98
- package/src/utils/template.ts +56 -56
- package/src/utils/tsconfig.ts +127 -127
- package/src/utils/typecheck-serialization.ts +86 -86
- package/templates/init/{.prettierrc.yaml.hbs → .prettierrc.yaml} +1 -1
- package/templates/init/eslint.config.ts +15 -0
- package/templates/init/mise.toml +3 -0
- package/templates/init/package.json.hbs +8 -7
- package/templates/init/packages/client-admin/index.html.hbs +144 -0
- package/templates/init/packages/client-admin/package.json.hbs +26 -0
- package/templates/init/packages/client-admin/public/assets/logo-landscape.png +0 -0
- package/templates/init/packages/client-admin/public/assets/logo.png +0 -0
- package/templates/init/packages/client-admin/src/App.tsx +42 -0
- package/templates/init/packages/client-admin/src/dev/DevDialog.tsx +34 -0
- package/templates/{add-client/__CLIENT__/src/main.css.hbs → init/packages/client-admin/src/main.css} +1 -1
- package/templates/init/packages/client-admin/src/main.tsx.hbs +146 -0
- package/templates/init/packages/client-admin/src/providers/AppServiceProvider.tsx.hbs +103 -0
- package/templates/init/packages/client-admin/src/providers/AppStructureProvider.tsx +84 -0
- package/templates/init/packages/client-admin/src/providers/AuthProvider.tsx.hbs +71 -0
- package/templates/init/packages/client-admin/src/providers/configureSharedData.ts.hbs +67 -0
- package/templates/init/packages/client-admin/src/views/auth/LoginView.tsx +132 -0
- package/templates/init/packages/client-admin/src/views/home/HomeView.tsx +108 -0
- package/templates/init/packages/client-admin/src/views/home/base/employee/EmployeeDetail.tsx.hbs +262 -0
- package/templates/init/packages/client-admin/src/views/home/base/employee/EmployeeSheet.tsx.hbs +271 -0
- package/templates/init/packages/client-admin/src/views/home/base/role-permission/RoleDetail.tsx.hbs +154 -0
- package/templates/init/packages/client-admin/src/views/home/base/role-permission/RolePermissionDetail.tsx.hbs +123 -0
- package/templates/init/packages/client-admin/src/views/home/base/role-permission/RolePermissionView.tsx +52 -0
- package/templates/init/packages/client-admin/src/views/home/base/role-permission/RoleSheet.tsx.hbs +125 -0
- package/templates/init/packages/client-admin/src/views/home/main/MainView.tsx.hbs +13 -0
- package/templates/init/packages/client-admin/src/views/home/my-info/MyInfoDetail.tsx.hbs +248 -0
- package/templates/init/packages/client-admin/src/views/home/system/system-log/SystemLogSheet.tsx.hbs +169 -0
- package/templates/init/packages/client-admin/src/views/not-found/NotFoundView.tsx +15 -0
- package/templates/init/packages/client-admin/tailwind.config.ts +10 -0
- package/templates/init/packages/db-main/package.json.hbs +13 -0
- package/templates/init/packages/db-main/src/MainDbContext.ts +20 -0
- package/templates/init/packages/db-main/src/dataLogExt.ts +127 -0
- package/templates/init/packages/db-main/src/index.ts +10 -0
- package/templates/init/packages/db-main/src/tables/Employee.ts +24 -0
- package/templates/init/packages/db-main/src/tables/EmployeeConfig.ts +13 -0
- package/templates/init/packages/db-main/src/tables/Role.ts +9 -0
- package/templates/init/packages/db-main/src/tables/RolePermission.ts +13 -0
- package/templates/init/packages/db-main/src/tables/_DataLog.ts +19 -0
- package/templates/init/packages/db-main/src/tables/_Log.ts +16 -0
- package/templates/init/packages/server/package.json.hbs +20 -0
- package/templates/init/packages/server/public-dev/dev//354/264/210/352/270/260/355/231/224.xlsx +0 -0
- package/templates/init/packages/server/src/index.ts +4 -0
- package/templates/init/packages/server/src/main.ts.hbs +34 -0
- package/templates/init/packages/server/src/services/AuthService.ts.hbs +171 -0
- package/templates/init/packages/server/src/services/DevService.ts.hbs +94 -0
- package/templates/init/packages/server/src/services/EmployeeService.ts.hbs +122 -0
- package/templates/init/packages/server/src/services/RoleService.ts.hbs +59 -0
- package/templates/init/{pnpm-workspace.yaml.hbs → pnpm-workspace.yaml} +3 -1
- package/templates/init/sd.config.ts.hbs +30 -1
- package/templates/init/tests/e2e/package.json.hbs +16 -0
- package/templates/init/tests/e2e/src/e2e.spec.ts +36 -0
- package/templates/init/tests/e2e/src/employee-crud.ts +204 -0
- package/templates/init/tests/e2e/src/login.ts +61 -0
- package/templates/init/tests/e2e/vitest.setup.ts.hbs +220 -0
- package/templates/init/tsconfig.json.hbs +0 -11
- package/templates/init/{vitest.config.ts.hbs → vitest.config.ts} +16 -12
- package/dist/commands/add-client.d.ts +0 -18
- package/dist/commands/add-client.d.ts.map +0 -1
- package/dist/commands/add-client.js +0 -79
- package/dist/commands/add-client.js.map +0 -6
- package/dist/commands/add-server.d.ts +0 -18
- package/dist/commands/add-server.d.ts.map +0 -1
- package/dist/commands/add-server.js +0 -83
- package/dist/commands/add-server.js.map +0 -6
- package/dist/utils/config-editor.d.ts +0 -17
- package/dist/utils/config-editor.d.ts.map +0 -1
- package/dist/utils/config-editor.js +0 -79
- package/dist/utils/config-editor.js.map +0 -6
- package/src/commands/add-client.ts +0 -126
- package/src/commands/add-server.ts +0 -138
- package/src/utils/config-editor.ts +0 -141
- package/templates/add-client/__CLIENT__/index.html.hbs +0 -13
- package/templates/add-client/__CLIENT__/package.json.hbs +0 -16
- package/templates/add-client/__CLIENT__/src/App.tsx.hbs +0 -65
- package/templates/add-client/__CLIENT__/src/appStructure.ts.hbs +0 -20
- package/templates/add-client/__CLIENT__/src/main.tsx.hbs +0 -24
- package/templates/add-client/__CLIENT__/src/pages/HomePage.tsx.hbs +0 -9
- package/templates/add-client/__CLIENT__/tailwind.config.ts.hbs +0 -15
- package/templates/add-server/__SERVER__/package.json.hbs +0 -10
- package/templates/add-server/__SERVER__/src/main.ts.hbs +0 -14
- package/templates/init/.gitignore.hbs +0 -26
- package/templates/init/.npmrc.hbs +0 -1
- package/templates/init/eslint.config.ts.hbs +0 -5
- package/templates/init/mise.toml.hbs +0 -3
- package/tests/config-editor.spec.ts +0 -160
- /package/templates/init/{.prettierignore.hbs → .prettierignore} +0 -0
- /package/templates/{add-client/__CLIENT__ → init/packages/client-admin}/public/favicon.ico +0 -0
- /package/templates/init/{stylelint.config.ts.hbs → stylelint.config.ts} +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
2
|
+
import type { Page } from "playwright";
|
|
3
|
+
|
|
4
|
+
export function employeeCrudTests(ctx: { page: Page; baseUrl: string }) {
|
|
5
|
+
describe("EmployeeSheet CRUD", () => {
|
|
6
|
+
const sheetSelector = '[data-sheet="employee-page-sheet"]';
|
|
7
|
+
|
|
8
|
+
function sheetRows() {
|
|
9
|
+
return ctx.page.locator(`${sheetSelector} tbody tr`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function dialogLocator() {
|
|
13
|
+
return ctx.page.locator("[data-dialog-panel]").last();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function waitForSheetLoaded() {
|
|
17
|
+
await sheetRows().first().waitFor({ timeout: 1000 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function waitForBusyDone() {
|
|
21
|
+
await ctx.page.waitForTimeout(50);
|
|
22
|
+
await ctx.page.waitForFunction(() => !document.querySelector(".animate-spin"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function waitForDialogClosed() {
|
|
26
|
+
await ctx.page.waitForFunction(
|
|
27
|
+
() =>
|
|
28
|
+
document.querySelectorAll("[data-dialog-panel]").length === 0 &&
|
|
29
|
+
document.querySelectorAll("[data-dialog-backdrop]").length === 0,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function assertNotification(text: string) {
|
|
34
|
+
const alert = ctx.page
|
|
35
|
+
.locator("[data-notification-banner]")
|
|
36
|
+
.filter({ hasText: text })
|
|
37
|
+
.first();
|
|
38
|
+
await alert.waitFor();
|
|
39
|
+
const closeButtons = ctx.page.locator(
|
|
40
|
+
'[data-notification-banner] button[aria-label="알림 닫기"]',
|
|
41
|
+
);
|
|
42
|
+
const count = await closeButtons.count();
|
|
43
|
+
for (let i = count - 1; i >= 0; i--) {
|
|
44
|
+
await closeButtons
|
|
45
|
+
.nth(i)
|
|
46
|
+
.click()
|
|
47
|
+
.catch(() => {});
|
|
48
|
+
}
|
|
49
|
+
await ctx.page.waitForFunction(
|
|
50
|
+
() => document.querySelectorAll("[data-notification-banner]").length === 0,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function clickSearch() {
|
|
55
|
+
await ctx.page.getByRole("button", { name: "조회" }).click();
|
|
56
|
+
await waitForBusyDone();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function clickAdd() {
|
|
60
|
+
await ctx.page.getByRole("button", { name: /등록/ }).click();
|
|
61
|
+
await dialogLocator().waitFor();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function clickEditRow(rowIndex: number) {
|
|
65
|
+
await sheetRows().nth(rowIndex).locator("a").first().click();
|
|
66
|
+
await dialogLocator().waitFor();
|
|
67
|
+
await waitForBusyDone();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function fillDialogField(label: string, value: string) {
|
|
71
|
+
const dlg = dialogLocator();
|
|
72
|
+
const th = dlg.locator("th").filter({ hasText: label });
|
|
73
|
+
const td = th.locator("xpath=following-sibling::td[1]");
|
|
74
|
+
const field = td.locator("input:not([aria-hidden])").first();
|
|
75
|
+
await field.fill(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function clickDialogSubmit() {
|
|
79
|
+
await dialogLocator().getByRole("button", { name: "확인" }).click();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function closeDialog() {
|
|
83
|
+
await ctx.page.keyboard.press("Escape");
|
|
84
|
+
await waitForDialogClosed();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
beforeAll(async () => {
|
|
88
|
+
await ctx.page.goto(`${ctx.baseUrl}/#/home/base/employee`);
|
|
89
|
+
await waitForSheetLoaded();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("Create", () => {
|
|
93
|
+
it("새 직원 등록", async () => {
|
|
94
|
+
await clickAdd();
|
|
95
|
+
|
|
96
|
+
await fillDialogField("이름", "E2E테스트유저");
|
|
97
|
+
await fillDialogField("이메일", "e2e@test.com");
|
|
98
|
+
|
|
99
|
+
await clickDialogSubmit();
|
|
100
|
+
await assertNotification("저장되었습니다");
|
|
101
|
+
await waitForDialogClosed();
|
|
102
|
+
await clickSearch();
|
|
103
|
+
|
|
104
|
+
const firstCell = sheetRows().first().locator("td").nth(1);
|
|
105
|
+
await expect(firstCell.textContent()).resolves.toContain("E2E테스트유저");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("Read / Filter", () => {
|
|
110
|
+
it("검색어 필터링", async () => {
|
|
111
|
+
const searchInput = ctx.page.locator("form").first().locator("input").first();
|
|
112
|
+
await searchInput.fill("E2E테스트");
|
|
113
|
+
await clickSearch();
|
|
114
|
+
|
|
115
|
+
const rowCount = await sheetRows().count();
|
|
116
|
+
expect(rowCount).toBe(1);
|
|
117
|
+
await expect(sheetRows().first().locator("td").nth(1).textContent()).resolves.toContain(
|
|
118
|
+
"E2E테스트유저",
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
await searchInput.fill("");
|
|
122
|
+
await clickSearch();
|
|
123
|
+
const allRowCount = await sheetRows().count();
|
|
124
|
+
expect(allRowCount).toBe(2);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("Update", () => {
|
|
129
|
+
it("기존 유저 이름 수정", async () => {
|
|
130
|
+
await clickEditRow(0);
|
|
131
|
+
|
|
132
|
+
await fillDialogField("이름", "E2E수정유저");
|
|
133
|
+
|
|
134
|
+
await clickDialogSubmit();
|
|
135
|
+
await assertNotification("저장되었습니다");
|
|
136
|
+
await waitForDialogClosed();
|
|
137
|
+
await clickSearch();
|
|
138
|
+
|
|
139
|
+
await expect(sheetRows().first().locator("td").nth(1).textContent()).resolves.toContain(
|
|
140
|
+
"E2E수정유저",
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("Delete", () => {
|
|
146
|
+
it("유저 soft delete", async () => {
|
|
147
|
+
await clickEditRow(0);
|
|
148
|
+
|
|
149
|
+
await dialogLocator().getByRole("button", { name: "삭제" }).click();
|
|
150
|
+
await assertNotification("삭제되었습니다");
|
|
151
|
+
await waitForDialogClosed();
|
|
152
|
+
await clickSearch();
|
|
153
|
+
|
|
154
|
+
const rowCount = await sheetRows().count();
|
|
155
|
+
expect(rowCount).toBe(1);
|
|
156
|
+
await expect(sheetRows().first().locator("td").nth(1).textContent()).resolves.toContain(
|
|
157
|
+
"테스트",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("삭제항목 포함 필터로 삭제된 유저 확인", async () => {
|
|
162
|
+
await ctx.page.getByText("삭제항목 포함").click();
|
|
163
|
+
await clickSearch();
|
|
164
|
+
|
|
165
|
+
const rowCount = await sheetRows().count();
|
|
166
|
+
expect(rowCount).toBe(2);
|
|
167
|
+
|
|
168
|
+
await ctx.page.getByText("삭제항목 포함").click();
|
|
169
|
+
await clickSearch();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("Error cases", () => {
|
|
174
|
+
it("이름 중복 에러", async () => {
|
|
175
|
+
await clickAdd();
|
|
176
|
+
await fillDialogField("이름", "테스트");
|
|
177
|
+
await clickDialogSubmit();
|
|
178
|
+
await assertNotification("동일한 이름이 이미 등록되어 있습니다");
|
|
179
|
+
|
|
180
|
+
await closeDialog();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("이메일 중복 에러", async () => {
|
|
184
|
+
await clickAdd();
|
|
185
|
+
await fillDialogField("이름", "고유이름");
|
|
186
|
+
await fillDialogField("이메일", "admin@test.com");
|
|
187
|
+
await clickDialogSubmit();
|
|
188
|
+
await assertNotification("동일한 이메일이 이미 등록되어 있습니다");
|
|
189
|
+
|
|
190
|
+
await closeDialog();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("자기 자신 삭제 불가", async () => {
|
|
194
|
+
await clickEditRow(0);
|
|
195
|
+
await waitForBusyDone();
|
|
196
|
+
|
|
197
|
+
const deleteBtn = dialogLocator().getByRole("button", { name: "삭제" });
|
|
198
|
+
expect(await deleteBtn.count()).toBe(0);
|
|
199
|
+
|
|
200
|
+
await closeDialog();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Page } from "playwright";
|
|
3
|
+
|
|
4
|
+
export function loginTests(ctx: { page: Page; baseUrl: string }) {
|
|
5
|
+
describe("Login", () => {
|
|
6
|
+
it("/ 접속 시 /login으로 리다이렉트", async () => {
|
|
7
|
+
await ctx.page.goto(`${ctx.baseUrl}/`, { timeout: 5000 });
|
|
8
|
+
await ctx.page.waitForURL(`${ctx.baseUrl}/#/login`);
|
|
9
|
+
await expect(
|
|
10
|
+
ctx.page.getByRole("button", { name: "로그인" }).textContent(),
|
|
11
|
+
).resolves.toContain("로그인");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("토큰 없이 /home/main 접근 시 /login으로 리다이렉트", async () => {
|
|
15
|
+
await ctx.page.goto(`${ctx.baseUrl}/#/home/main`);
|
|
16
|
+
await ctx.page.waitForURL(`${ctx.baseUrl}/#/login`);
|
|
17
|
+
await expect(
|
|
18
|
+
ctx.page.getByRole("button", { name: "로그인" }).textContent(),
|
|
19
|
+
).resolves.toContain("로그인");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("틀린 비밀번호 시 에러 메시지", async () => {
|
|
23
|
+
await ctx.page.getByPlaceholder("이메일을 입력하세요").fill("admin@test.com");
|
|
24
|
+
await ctx.page.getByPlaceholder("비밀번호를 입력하세요").fill("wrongpassword");
|
|
25
|
+
await ctx.page.getByRole("button", { name: "로그인" }).click();
|
|
26
|
+
|
|
27
|
+
const notification = ctx.page.getByRole("alert");
|
|
28
|
+
await notification.waitFor({ timeout: 1000 });
|
|
29
|
+
await expect(notification.textContent()).resolves.toContain(
|
|
30
|
+
"이메일 또는 비밀번호가 올바르지 않습니다",
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("올바른 자격 증명으로 로그인 성공", async () => {
|
|
35
|
+
await ctx.page.goto(`${ctx.baseUrl}/#/login`);
|
|
36
|
+
await ctx.page.getByRole("button", { name: "로그인" }).waitFor({ timeout: 2000 });
|
|
37
|
+
|
|
38
|
+
await ctx.page.getByPlaceholder("이메일을 입력하세요").fill("admin@test.com");
|
|
39
|
+
await ctx.page.getByPlaceholder("비밀번호를 입력하세요").fill("test1234");
|
|
40
|
+
await ctx.page.getByRole("button", { name: "로그인" }).click({ timeout: 2000 });
|
|
41
|
+
|
|
42
|
+
await ctx.page.waitForURL(`${ctx.baseUrl}/#/home/main`, { timeout: 2000 });
|
|
43
|
+
const mainContent = ctx.page.locator("main").last();
|
|
44
|
+
await expect(mainContent.locator("h1").textContent()).resolves.toContain("테스트");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("마지막 로그인 이메일 기억", async () => {
|
|
48
|
+
const lastLoginEmail = await ctx.page.evaluate(() =>
|
|
49
|
+
JSON.parse(localStorage.getItem("client-admin.last-login-email") ?? "null"),
|
|
50
|
+
);
|
|
51
|
+
expect(lastLoginEmail).toBe("admin@test.com");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("페이지 새로고침 시 자동 로그인", async () => {
|
|
55
|
+
await ctx.page.reload();
|
|
56
|
+
await ctx.page.waitForURL(`${ctx.baseUrl}/#/home/main`, { timeout: 2000 });
|
|
57
|
+
const mainContent = ctx.page.locator("main").last();
|
|
58
|
+
await expect(mainContent.locator("h1").textContent()).resolves.toContain("테스트");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { type ChildProcess, spawn, execSync } from "child_process";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { createDbConn, createOrm } from "@simplysm/orm-node";
|
|
5
|
+
import { MainDbContext } from "@{{projectName}}/db-main";
|
|
6
|
+
import bcrypt from "bcrypt";
|
|
7
|
+
import { chromium } from "playwright";
|
|
8
|
+
import type { TestProject } from "vitest/node";
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const rootDir = path.resolve(__dirname, "../..");
|
|
12
|
+
|
|
13
|
+
const DB_CONFIG = {
|
|
14
|
+
dialect: "postgresql" as const,
|
|
15
|
+
host: "localhost",
|
|
16
|
+
port: 5432,
|
|
17
|
+
username: "postgres",
|
|
18
|
+
password: "1234",
|
|
19
|
+
database: "{{projectName}}-test",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const TEST_USER = {
|
|
23
|
+
name: "테스트",
|
|
24
|
+
email: "admin@test.com",
|
|
25
|
+
password: "test1234",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let devServerProcess: ChildProcess | undefined;
|
|
29
|
+
|
|
30
|
+
function killProcessTree(proc: ChildProcess): void {
|
|
31
|
+
if (proc.pid == null) return;
|
|
32
|
+
try {
|
|
33
|
+
if (process.platform === "win32") {
|
|
34
|
+
execSync(`taskkill /T /F /PID ${proc.pid}`, { stdio: "ignore" });
|
|
35
|
+
} else {
|
|
36
|
+
process.kill(-proc.pid, "SIGTERM");
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
/* 무시 */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stripAnsi(str: string): string {
|
|
44
|
+
return str.replace(/\x1B\[[0-9;]*m/g, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function waitForServerUrl(process: ChildProcess, timeout: number): Promise<string> {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
let settled = false;
|
|
50
|
+
let output = "";
|
|
51
|
+
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
if (!settled) {
|
|
54
|
+
settled = true;
|
|
55
|
+
reject(
|
|
56
|
+
new Error(`dev 서버 URL 대기 시간 초과 (${timeout}ms)\n출력:\n${output.slice(-2000)}`),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}, timeout);
|
|
60
|
+
|
|
61
|
+
function onData(data: Buffer) {
|
|
62
|
+
const text = stripAnsi(data.toString());
|
|
63
|
+
console.log(text);
|
|
64
|
+
output += text;
|
|
65
|
+
|
|
66
|
+
const urlMatch = text.match(/http:\/\/localhost:\d+\/client-admin\/?/);
|
|
67
|
+
if (urlMatch && !settled) {
|
|
68
|
+
settled = true;
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
resolve(urlMatch[0].endsWith("/") ? urlMatch[0].slice(0, -1) : urlMatch[0]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
process.stdout?.on("data", onData);
|
|
75
|
+
process.stderr?.on("data", onData);
|
|
76
|
+
|
|
77
|
+
process.on("exit", (code) => {
|
|
78
|
+
if (!settled) {
|
|
79
|
+
settled = true;
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
reject(
|
|
82
|
+
new Error(`dev 서버가 비정상 종료됨 (code: ${code})\n출력:\n${output.slice(-2000)}`),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function warmup(baseUrl: string, timeout: number): Promise<void> {
|
|
90
|
+
const browser = await chromium.launch({ headless: true });
|
|
91
|
+
try {
|
|
92
|
+
const page = await browser.newPage();
|
|
93
|
+
await page.goto(baseUrl, { timeout });
|
|
94
|
+
|
|
95
|
+
const result = await Promise.race([
|
|
96
|
+
page
|
|
97
|
+
.locator(".app-loading")
|
|
98
|
+
.waitFor({ state: "detached", timeout })
|
|
99
|
+
.then(() => "ready" as const),
|
|
100
|
+
page
|
|
101
|
+
.locator("vite-error-overlay")
|
|
102
|
+
.waitFor({ state: "attached", timeout })
|
|
103
|
+
.then(async () => {
|
|
104
|
+
const errorText = await page
|
|
105
|
+
.locator("vite-error-overlay")
|
|
106
|
+
.evaluate((el) => el.shadowRoot?.textContent ?? "");
|
|
107
|
+
return `error:${errorText}` as const;
|
|
108
|
+
}),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
if (result.startsWith("error:")) {
|
|
112
|
+
throw new Error(`Vite 빌드 에러:\n${result.slice(6).slice(0, 2000)}`);
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
await browser.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function setup(project: TestProject) {
|
|
120
|
+
// 1. 테스트 DB 생성 (없으면)
|
|
121
|
+
console.log("[e2e] 테스트 DB 확인...");
|
|
122
|
+
const adminConn = await createDbConn({ ...DB_CONFIG, database: "postgres" });
|
|
123
|
+
await adminConn.connect();
|
|
124
|
+
try {
|
|
125
|
+
const result = await adminConn.executeParametrized(
|
|
126
|
+
`SELECT 1 FROM pg_database WHERE datname = $1`,
|
|
127
|
+
[DB_CONFIG.database],
|
|
128
|
+
);
|
|
129
|
+
if (result[0].length === 0) {
|
|
130
|
+
await adminConn.execute([`CREATE DATABASE "${DB_CONFIG.database}"`]);
|
|
131
|
+
console.log(`[e2e] DB "${DB_CONFIG.database}" 생성됨.`);
|
|
132
|
+
} else {
|
|
133
|
+
console.log(`[e2e] DB "${DB_CONFIG.database}" 이미 존재.`);
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
await adminConn.close();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 2. DB 초기화 및 시딩
|
|
140
|
+
const orm = createOrm(MainDbContext, DB_CONFIG);
|
|
141
|
+
await orm.connectWithoutTransaction(async (db) => {
|
|
142
|
+
await db.initialize({ force: true });
|
|
143
|
+
|
|
144
|
+
// 권한그룹 생성
|
|
145
|
+
await db.role().insert([{ name: "관리자" }]);
|
|
146
|
+
const role = (await db.role().first())!;
|
|
147
|
+
|
|
148
|
+
// 전체 권한 부여
|
|
149
|
+
await db.rolePermission().insert([
|
|
150
|
+
{ roleId: role.id, code: "/home/base/employee/use", valueJson: "true" },
|
|
151
|
+
{ roleId: role.id, code: "/home/base/employee/edit", valueJson: "true" },
|
|
152
|
+
{ roleId: role.id, code: "/home/base/employee/auth/use", valueJson: "true" },
|
|
153
|
+
{ roleId: role.id, code: "/home/base/employee/auth/edit", valueJson: "true" },
|
|
154
|
+
{ roleId: role.id, code: "/home/base/employee/personal/use", valueJson: "true" },
|
|
155
|
+
{ roleId: role.id, code: "/home/base/employee/personal/edit", valueJson: "true" },
|
|
156
|
+
{ roleId: role.id, code: "/home/base/employee/payroll/use", valueJson: "true" },
|
|
157
|
+
{ roleId: role.id, code: "/home/base/employee/payroll/edit", valueJson: "true" },
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
// 테스트 유저 생성 (roleId 포함)
|
|
161
|
+
const encryptedPassword = await bcrypt.hash(TEST_USER.password, 10);
|
|
162
|
+
await db.employee().insert([
|
|
163
|
+
{
|
|
164
|
+
name: TEST_USER.name,
|
|
165
|
+
email: TEST_USER.email,
|
|
166
|
+
encryptedPassword,
|
|
167
|
+
roleId: role.id,
|
|
168
|
+
isDeleted: false,
|
|
169
|
+
},
|
|
170
|
+
]);
|
|
171
|
+
});
|
|
172
|
+
console.log("[e2e] DB 초기화 및 시딩 완료.");
|
|
173
|
+
|
|
174
|
+
// 3. dev 서버 시작
|
|
175
|
+
console.log("[e2e] dev 서버 시작...");
|
|
176
|
+
devServerProcess = spawn("node", ["node_modules/@simplysm/sd-cli/dist/sd-cli.js", "dev"], {
|
|
177
|
+
cwd: rootDir,
|
|
178
|
+
stdio: "pipe",
|
|
179
|
+
...(process.platform !== "win32" ? { detached: true } : {}),
|
|
180
|
+
env: {
|
|
181
|
+
...process.env,
|
|
182
|
+
DB_DATABASE: DB_CONFIG.database,
|
|
183
|
+
DB_PORT: String(DB_CONFIG.port),
|
|
184
|
+
NODE_ENV: undefined, // consola가 콘솔을 강제로 warn이상으로 설정하지 못하도록 하기 위함
|
|
185
|
+
TEST: undefined, // consola가 콘솔을 강제로 warn이상으로 설정하지 못하도록 하기 위함
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
let baseUrl: string;
|
|
190
|
+
try {
|
|
191
|
+
// 4. stdout에서 URL 파싱 (90초 타임아웃)
|
|
192
|
+
baseUrl = await waitForServerUrl(devServerProcess, 90_000);
|
|
193
|
+
console.log(`[e2e] dev 서버 URL 감지: ${baseUrl}`);
|
|
194
|
+
|
|
195
|
+
// 5. Playwright warmup — Vite 빌드 트리거 및 에러 감지 (60초 타임아웃)
|
|
196
|
+
console.log("[e2e] Vite 빌드 warmup...");
|
|
197
|
+
await warmup(baseUrl, 60_000);
|
|
198
|
+
console.log("[e2e] warmup 완료. 테스트 시작 준비됨.");
|
|
199
|
+
|
|
200
|
+
project.provide("baseUrl", baseUrl);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
killProcessTree(devServerProcess);
|
|
203
|
+
devServerProcess = undefined;
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function teardown() {
|
|
209
|
+
if (devServerProcess != null) {
|
|
210
|
+
console.log("[e2e] dev 서버 종료...");
|
|
211
|
+
killProcessTree(devServerProcess);
|
|
212
|
+
devServerProcess = undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
declare module "vitest" {
|
|
217
|
+
export interface ProvidedContext {
|
|
218
|
+
baseUrl: string;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
|
-
// 출력
|
|
4
3
|
"target": "ESNext",
|
|
5
4
|
"module": "ESNext",
|
|
6
5
|
"lib": ["ESNext", "DOM", "DOM.Iterable", "WebWorker"],
|
|
7
6
|
"moduleResolution": "bundler",
|
|
8
|
-
|
|
9
|
-
// JSX (SolidJS)
|
|
10
7
|
"jsx": "preserve",
|
|
11
8
|
"jsxImportSource": "solid-js",
|
|
12
|
-
|
|
13
|
-
// Strict
|
|
14
9
|
"strict": true,
|
|
15
10
|
"noImplicitOverride": true,
|
|
16
11
|
"noImplicitReturns": true,
|
|
@@ -20,19 +15,13 @@
|
|
|
20
15
|
"noPropertyAccessFromIndexSignature": true,
|
|
21
16
|
"forceConsistentCasingInFileNames": true,
|
|
22
17
|
"useDefineForClassFields": true,
|
|
23
|
-
|
|
24
|
-
// 모듈
|
|
25
18
|
"esModuleInterop": true,
|
|
26
19
|
"verbatimModuleSyntax": true,
|
|
27
|
-
|
|
28
|
-
// 빌드
|
|
29
20
|
"declaration": true,
|
|
30
21
|
"declarationMap": true,
|
|
31
22
|
"sourceMap": true,
|
|
32
23
|
"skipLibCheck": true,
|
|
33
24
|
"importHelpers": true,
|
|
34
|
-
|
|
35
|
-
// 경로
|
|
36
25
|
"baseUrl": ".",
|
|
37
26
|
"paths": {
|
|
38
27
|
"@{{projectName}}/*": ["packages/*/src/index.ts"]
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
1
3
|
import { defineConfig } from "vitest/config";
|
|
2
4
|
import tsconfigPaths from "vite-tsconfig-paths";
|
|
3
5
|
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
4
8
|
export default defineConfig({
|
|
5
9
|
plugins: [
|
|
6
10
|
tsconfigPaths({
|
|
@@ -24,22 +28,22 @@ export default defineConfig({
|
|
|
24
28
|
{
|
|
25
29
|
extends: true,
|
|
26
30
|
test: {
|
|
27
|
-
name: "
|
|
28
|
-
include: ["packages/*/tests/**/*.spec.{ts,tsx}"],
|
|
31
|
+
name: "node",
|
|
32
|
+
include: ["packages/*/tests/**/*.spec.{ts,tsx,js}"],
|
|
29
33
|
testTimeout: 30000,
|
|
30
34
|
},
|
|
31
35
|
},
|
|
32
36
|
// E2E 테스트 (Docker + dev 서버 필요)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
{
|
|
38
|
+
extends: true,
|
|
39
|
+
test: {
|
|
40
|
+
name: "e2e",
|
|
41
|
+
include: ["tests/e2e/src/**/*.spec.ts"],
|
|
42
|
+
globalSetup: "./tests/e2e/vitest.setup.ts",
|
|
43
|
+
fileParallelism: false,
|
|
44
|
+
testTimeout: 30000,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
43
47
|
],
|
|
44
48
|
},
|
|
45
49
|
});
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Add-client command options
|
|
3
|
-
*/
|
|
4
|
-
export interface AddClientOptions {
|
|
5
|
-
}
|
|
6
|
-
/**
|
|
7
|
-
* Add client package to the project.
|
|
8
|
-
*
|
|
9
|
-
* 1. Verify project root (sd.config.ts exists)
|
|
10
|
-
* 2. Interactive prompt (name suffix, router usage)
|
|
11
|
-
* 3. Check for duplicate package directory
|
|
12
|
-
* 4. Render Handlebars template
|
|
13
|
-
* 5. Add package entry to sd.config.ts (ts-morph)
|
|
14
|
-
* 6. Add tailwind configuration to eslint.config.ts (if first client)
|
|
15
|
-
* 7. pnpm install
|
|
16
|
-
*/
|
|
17
|
-
export declare function runAddClient(_options: AddClientOptions): Promise<void>;
|
|
18
|
-
//# sourceMappingURL=add-client.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"add-client.d.ts","sourceRoot":"","sources":["..\\..\\src\\commands\\add-client.ts"],"names":[],"mappings":"AAWA;;GAEG;AACH,MAAM,WAAW,gBAAgB;CAAG;AASpC;;;;;;;;;;GAUG;AACH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyF5E"}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import path from "path";
|
|
2
|
-
import fs from "fs";
|
|
3
|
-
import { input, confirm } from "@inquirer/prompts";
|
|
4
|
-
import { consola } from "consola";
|
|
5
|
-
import { renderTemplateDir } from "../utils/template.js";
|
|
6
|
-
import { addPackageToSdConfig, addTailwindToEslintConfig } from "../utils/config-editor.js";
|
|
7
|
-
import { execa } from "execa";
|
|
8
|
-
import { findPackageRoot } from "../utils/package-utils.js";
|
|
9
|
-
async function runAddClient(_options) {
|
|
10
|
-
const cwd = process.cwd();
|
|
11
|
-
const logger = consola.withTag("sd:cli:add-client");
|
|
12
|
-
if (!fs.existsSync(path.join(cwd, "sd.config.ts"))) {
|
|
13
|
-
consola.error("Cannot find sd.config.ts. Please run from the project root.");
|
|
14
|
-
process.exitCode = 1;
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
17
|
-
const projectName = path.basename(cwd);
|
|
18
|
-
const clientSuffix = await input({
|
|
19
|
-
message: "Enter client name suffix (client-___):",
|
|
20
|
-
validate: (value) => {
|
|
21
|
-
if (!value.trim()) return "Please enter a name.";
|
|
22
|
-
if (!/^[a-z][a-z0-9-]*$/.test(value)) return "Only lowercase letters, numbers, and hyphens are allowed.";
|
|
23
|
-
return true;
|
|
24
|
-
}
|
|
25
|
-
});
|
|
26
|
-
const useRouter = await confirm({
|
|
27
|
-
message: "Do you want to use router?",
|
|
28
|
-
default: true
|
|
29
|
-
});
|
|
30
|
-
const clientName = `client-${clientSuffix}`;
|
|
31
|
-
const packageDir = path.join(cwd, "packages", clientName);
|
|
32
|
-
if (fs.existsSync(packageDir)) {
|
|
33
|
-
consola.error(`packages/${clientName} directory already exists.`);
|
|
34
|
-
process.exitCode = 1;
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
const pkgRoot = findPackageRoot(import.meta.dirname);
|
|
38
|
-
const templateDir = path.join(pkgRoot, "templates", "add-client");
|
|
39
|
-
const context = {
|
|
40
|
-
projectName,
|
|
41
|
-
clientSuffix,
|
|
42
|
-
clientName,
|
|
43
|
-
router: useRouter
|
|
44
|
-
};
|
|
45
|
-
const dirReplacements = {
|
|
46
|
-
__CLIENT__: clientName
|
|
47
|
-
};
|
|
48
|
-
logger.info(`Creating ${clientName} package...`);
|
|
49
|
-
await renderTemplateDir(templateDir, path.join(cwd, "packages"), context, dirReplacements);
|
|
50
|
-
logger.success(`packages/${clientName} created successfully`);
|
|
51
|
-
const sdConfigPath = path.join(cwd, "sd.config.ts");
|
|
52
|
-
const added = addPackageToSdConfig(sdConfigPath, clientName, { target: "client" });
|
|
53
|
-
if (added) {
|
|
54
|
-
logger.success("sd.config.ts updated successfully");
|
|
55
|
-
} else {
|
|
56
|
-
consola.warn(`"${clientName}" already exists in sd.config.ts.`);
|
|
57
|
-
}
|
|
58
|
-
const eslintConfigPath = path.join(cwd, "eslint.config.ts");
|
|
59
|
-
if (fs.existsSync(eslintConfigPath)) {
|
|
60
|
-
const tailwindAdded = addTailwindToEslintConfig(eslintConfigPath, clientName);
|
|
61
|
-
if (tailwindAdded) {
|
|
62
|
-
logger.success("Added tailwind configuration to eslint.config.ts");
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
logger.info("Running pnpm install...");
|
|
66
|
-
await execa("pnpm", ["install"], { cwd });
|
|
67
|
-
logger.success("pnpm install completed");
|
|
68
|
-
consola.box(
|
|
69
|
-
[
|
|
70
|
-
`Client "${clientName}" has been added!`,
|
|
71
|
-
"",
|
|
72
|
-
` pnpm dev ${clientName} Run development server`
|
|
73
|
-
].join("\n")
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
export {
|
|
77
|
-
runAddClient
|
|
78
|
-
};
|
|
79
|
-
//# sourceMappingURL=add-client.js.map
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../src/commands/add-client.ts"],
|
|
4
|
-
"mappings": "AAAA,OAAO,UAAU;AACjB,OAAO,QAAQ;AACf,SAAS,OAAO,eAAe;AAC/B,SAAS,eAAe;AACxB,SAAS,yBAAyB;AAClC,SAAS,sBAAsB,iCAAiC;AAChE,SAAS,aAAa;AACtB,SAAS,uBAAuB;AA2BhC,eAAsB,aAAa,UAA2C;AAC5E,QAAM,MAAM,QAAQ,IAAI;AACxB,QAAM,SAAS,QAAQ,QAAQ,mBAAmB;AAGlD,MAAI,CAAC,GAAG,WAAW,KAAK,KAAK,KAAK,cAAc,CAAC,GAAG;AAClD,YAAQ,MAAM,6DAA6D;AAC3E,YAAQ,WAAW;AACnB;AAAA,EACF;AAGA,QAAM,cAAc,KAAK,SAAS,GAAG;AAGrC,QAAM,eAAe,MAAM,MAAM;AAAA,IAC/B,SAAS;AAAA,IACT,UAAU,CAAC,UAAU;AACnB,UAAI,CAAC,MAAM,KAAK,EAAG,QAAO;AAC1B,UAAI,CAAC,oBAAoB,KAAK,KAAK,EAAG,QAAO;AAC7C,aAAO;AAAA,IACT;AAAA,EACF,CAAC;AAED,QAAM,YAAY,MAAM,QAAQ;AAAA,IAC9B,SAAS;AAAA,IACT,SAAS;AAAA,EACX,CAAC;AAED,QAAM,aAAa,UAAU,YAAY;AAGzC,QAAM,aAAa,KAAK,KAAK,KAAK,YAAY,UAAU;AACxD,MAAI,GAAG,WAAW,UAAU,GAAG;AAC7B,YAAQ,MAAM,YAAY,UAAU,4BAA4B;AAChE,YAAQ,WAAW;AACnB;AAAA,EACF;AAGA,QAAM,UAAU,gBAAgB,YAAY,OAAO;AACnD,QAAM,cAAc,KAAK,KAAK,SAAS,aAAa,YAAY;AAEhE,QAAM,UAAU;AAAA,IACd;AAAA,IACA;AAAA,IACA;AAAA,IACA,QAAQ;AAAA,EACV;AAEA,QAAM,kBAAkB;AAAA,IACtB,YAAY;AAAA,EACd;AAEA,SAAO,KAAK,YAAY,UAAU,aAAa;AAC/C,QAAM,kBAAkB,aAAa,KAAK,KAAK,KAAK,UAAU,GAAG,SAAS,eAAe;AACzF,SAAO,QAAQ,YAAY,UAAU,uBAAuB;AAG5D,QAAM,eAAe,KAAK,KAAK,KAAK,cAAc;AAClD,QAAM,QAAQ,qBAAqB,cAAc,YAAY,EAAE,QAAQ,SAAS,CAAC;AACjF,MAAI,OAAO;AACT,WAAO,QAAQ,mCAAmC;AAAA,EACpD,OAAO;AACL,YAAQ,KAAK,IAAI,UAAU,mCAAmC;AAAA,EAChE;AAGA,QAAM,mBAAmB,KAAK,KAAK,KAAK,kBAAkB;AAC1D,MAAI,GAAG,WAAW,gBAAgB,GAAG;AACnC,UAAM,gBAAgB,0BAA0B,kBAAkB,UAAU;AAC5E,QAAI,eAAe;AACjB,aAAO,QAAQ,kDAAkD;AAAA,IACnE;AAAA,EACF;AAGA,SAAO,KAAK,yBAAyB;AACrC,QAAM,MAAM,QAAQ,CAAC,SAAS,GAAG,EAAE,IAAI,CAAC;AACxC,SAAO,QAAQ,wBAAwB;AAGvC,UAAQ;AAAA,IACN;AAAA,MACE,WAAW,UAAU;AAAA,MACrB;AAAA,MACA,cAAc,UAAU;AAAA,IAC1B,EAAE,KAAK,IAAI;AAAA,EACb;AACF;",
|
|
5
|
-
"names": []
|
|
6
|
-
}
|