Skip to content

Commit 83577c2

Browse files
authored
feat: initialize Vagas Full application with Electron, React, and Nod… (#20)
…e.js - Added package.json with project metadata, dependencies, and build scripts. - Created loading.html for a loading screen with a spinner and status message. - Implemented main.js to manage the Electron app lifecycle, including backend startup and health checks. - Added preload.js for potential IPC helpers in the future.
2 parents 5a67d3c + 4374c4a commit 83577c2

13 files changed

Lines changed: 6589 additions & 1735 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,7 @@ coverage/
1717
frontend/coverage/
1818
backend/coverage/
1919
*.lcov
20-
.nyc_output/
20+
.nyc_output/
21+
22+
# Dist-electron
23+
dist-electron/

backend/src/jobsApiApp.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import express from "express";
33
import { existsSync, mkdirSync, readdirSync, statSync } from "fs";
44
import path from "path";
55
import XLSX from "xlsx";
6+
import { run as runScraper } from "./app.js";
67

78
/**
89
* @param {{ outputDir?: string }} [options]
910
*/
1011
export function createJobsApiApp(options = {}) {
1112
const outputDir = options.outputDir ?? path.resolve(process.cwd(), "output");
1213
const app = express();
14+
let activeScraperRun = null;
1315

1416
app.use(cors());
1517

@@ -99,5 +101,33 @@ export function createJobsApiApp(options = {}) {
99101
}
100102
});
101103

104+
app.post("/api/scraper/run", async (_req, res) => {
105+
if (activeScraperRun) {
106+
return res.status(409).json({
107+
message: "O scraper ja esta em execucao.",
108+
});
109+
}
110+
111+
try {
112+
activeScraperRun = runScraper();
113+
await activeScraperRun;
114+
115+
const files = listXlsxFiles();
116+
return res.json({
117+
ok: true,
118+
file: files[0]?.file ?? null,
119+
modifiedAt: files[0]?.modifiedAt ?? null,
120+
totalFiles: files.length,
121+
});
122+
} catch (error) {
123+
return res.status(500).json({
124+
message: "Erro ao executar o scraper.",
125+
error: error?.message || "Erro desconhecido",
126+
});
127+
} finally {
128+
activeScraperRun = null;
129+
}
130+
});
131+
102132
return app;
103133
}

backend/src/server.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,35 @@
11
import "dotenv/config";
2+
import express from "express";
3+
import { existsSync } from "fs";
24
import path from "path";
35
import { createJobsApiApp } from "./jobsApiApp.js";
46

57
const PORT = Number(process.env.PORT || 3001);
6-
const app = createJobsApiApp({
7-
outputDir: path.resolve(process.cwd(), "output"),
8-
});
8+
9+
// When running inside Electron, main.js sets ELECTRON_OUTPUT_DIR to a
10+
// writable user-data path. Fall back to the CWD-relative folder for dev/CLI.
11+
const outputDir = process.env.ELECTRON_OUTPUT_DIR
12+
? process.env.ELECTRON_OUTPUT_DIR
13+
: path.resolve(process.cwd(), "output");
14+
15+
const app = createJobsApiApp({ outputDir });
16+
17+
// ── Electron static-file serving ───────────────────────────────────────────
18+
// When ELECTRON_STATIC_DIR is set, serve the React production build so that
19+
// the Electron window can load everything from http://localhost:<PORT>.
20+
const staticDir = process.env.ELECTRON_STATIC_DIR;
21+
if (staticDir && existsSync(staticDir)) {
22+
app.use(express.static(staticDir));
23+
24+
// SPA catch-all: return index.html for any non-API route so that React
25+
// Router (if used in future) works correctly.
26+
app.use((req, res) => {
27+
if (req.path.startsWith("/api")) {
28+
return res.status(404).json({ error: "Rota não encontrada." });
29+
}
30+
res.sendFile(path.join(staticDir, "index.html"));
31+
});
32+
}
933

1034
app.listen(PORT, () => {
1135
// eslint-disable-next-line no-console

backend/tests/integration/jobsApi.test.js

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,27 @@ import { mkdtempSync } from "fs";
22
import { tmpdir } from "os";
33
import { join } from "path";
44
import request from "supertest";
5-
import { afterEach, describe, expect, it } from "vitest";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import XLSX from "xlsx";
7+
8+
const mocks = vi.hoisted(() => ({
9+
run: vi.fn(),
10+
}));
11+
12+
vi.mock("../../src/app.js", () => ({
13+
run: mocks.run,
14+
}));
15+
716
import { createJobsApiApp } from "../../src/jobsApiApp.js";
817

918
describe("jobs API", () => {
1019
let tmpDir;
1120

21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
mocks.run.mockResolvedValue(undefined);
24+
});
25+
1226
afterEach(() => {
1327
tmpDir = undefined;
1428
});
@@ -63,4 +77,67 @@ describe("jobs API", () => {
6377
const res = await request(app).get("/api/jobs").query({ file: "nao-existe.xlsx" }).expect(404);
6478
expect(res.body.message).toBe("Arquivo solicitado nao encontrado.");
6579
});
80+
81+
it("POST /api/scraper/run executa scraper e retorna metadados", async () => {
82+
tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-"));
83+
const workbook = XLSX.utils.book_new();
84+
const sheet = XLSX.utils.json_to_sheet([{ titulo: "x" }]);
85+
XLSX.utils.book_append_sheet(workbook, sheet, "Vagas");
86+
XLSX.writeFile(workbook, join(tmpDir, "resultado.xlsx"));
87+
88+
const app = createJobsApiApp({ outputDir: tmpDir });
89+
const res = await request(app).post("/api/scraper/run").expect(200);
90+
91+
expect(mocks.run).toHaveBeenCalledTimes(1);
92+
expect(res.body.ok).toBe(true);
93+
expect(res.body.file).toBe("resultado.xlsx");
94+
expect(res.body.totalFiles).toBe(1);
95+
});
96+
97+
it("POST /api/scraper/run retorna 409 quando ja existe execucao ativa", async () => {
98+
tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-"));
99+
let finishRun;
100+
mocks.run.mockImplementation(
101+
() =>
102+
new Promise((resolve) => {
103+
finishRun = resolve;
104+
}),
105+
);
106+
107+
const app = createJobsApiApp({ outputDir: tmpDir });
108+
109+
const firstRequest = new Promise((resolve, reject) => {
110+
request(app)
111+
.post("/api/scraper/run")
112+
.end((error, response) => {
113+
if (error) {
114+
reject(error);
115+
return;
116+
}
117+
resolve(response);
118+
});
119+
});
120+
121+
await vi.waitFor(() => {
122+
expect(mocks.run).toHaveBeenCalledTimes(1);
123+
});
124+
125+
const conflict = await request(app).post("/api/scraper/run").expect(409);
126+
expect(conflict.body.message).toBe("O scraper ja esta em execucao.");
127+
128+
finishRun();
129+
const firstResult = await firstRequest;
130+
expect(firstResult.status).toBe(200);
131+
});
132+
133+
it("POST /api/scraper/run retorna 500 quando scraper falha", async () => {
134+
tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-"));
135+
mocks.run.mockRejectedValue(new Error("falha no scraper"));
136+
137+
const app = createJobsApiApp({ outputDir: tmpDir });
138+
const res = await request(app).post("/api/scraper/run").expect(500);
139+
140+
expect(res.body.message).toBe("Erro ao executar o scraper.");
141+
expect(res.body.error).toBe("falha no scraper");
142+
});
66143
});

backend/tests/unit/services/server.test.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,45 @@
1+
import path from "path";
12
import { beforeEach, describe, expect, it, vi } from "vitest";
23

34
const mocks = vi.hoisted(() => ({
45
listen: vi.fn(),
6+
use: vi.fn(),
57
createJobsApiApp: vi.fn(),
68
consoleLog: vi.fn(),
9+
existsSync: vi.fn(),
10+
staticMiddleware: vi.fn(),
11+
expressStatic: vi.fn(),
712
}));
813

914
mocks.createJobsApiApp.mockReturnValue({
1015
listen: mocks.listen,
16+
use: mocks.use,
1117
});
1218

19+
mocks.expressStatic.mockReturnValue(mocks.staticMiddleware);
20+
1321
vi.mock("../../../src/jobsApiApp.js", () => ({
1422
createJobsApiApp: mocks.createJobsApiApp,
1523
}));
1624

25+
vi.mock("fs", () => ({
26+
existsSync: mocks.existsSync,
27+
}));
28+
29+
vi.mock("express", () => ({
30+
default: {
31+
static: mocks.expressStatic,
32+
},
33+
}));
34+
1735
describe("server entry", () => {
1836
beforeEach(() => {
1937
vi.resetModules();
2038
vi.clearAllMocks();
2139
process.env.PORT = "3100";
40+
delete process.env.ELECTRON_STATIC_DIR;
41+
delete process.env.ELECTRON_OUTPUT_DIR;
42+
mocks.existsSync.mockReturnValue(false);
2243
vi.spyOn(console, "log").mockImplementation(mocks.consoleLog);
2344
});
2445

@@ -41,4 +62,46 @@ describe("server entry", () => {
4162

4263
expect(mocks.listen).toHaveBeenCalledWith(3001, expect.any(Function));
4364
});
65+
66+
it("usa ELECTRON_OUTPUT_DIR quando definido", async () => {
67+
process.env.ELECTRON_OUTPUT_DIR = "/tmp/electron-output";
68+
69+
await import("../../../src/server.js");
70+
71+
expect(mocks.createJobsApiApp).toHaveBeenCalledWith({
72+
outputDir: "/tmp/electron-output",
73+
});
74+
});
75+
76+
it("registra static e fallback SPA quando ELECTRON_STATIC_DIR existe", async () => {
77+
process.env.ELECTRON_STATIC_DIR = "/tmp/frontend-dist";
78+
mocks.existsSync.mockReturnValue(true);
79+
80+
await import("../../../src/server.js");
81+
82+
expect(mocks.expressStatic).toHaveBeenCalledWith("/tmp/frontend-dist");
83+
expect(mocks.use).toHaveBeenCalledTimes(2);
84+
expect(mocks.use).toHaveBeenNthCalledWith(1, mocks.staticMiddleware);
85+
86+
const fallbackHandler = mocks.use.mock.calls[1][0];
87+
88+
const apiRes = {
89+
status: vi.fn().mockReturnThis(),
90+
json: vi.fn(),
91+
sendFile: vi.fn(),
92+
};
93+
fallbackHandler({ path: "/api/unknown" }, apiRes);
94+
expect(apiRes.status).toHaveBeenCalledWith(404);
95+
expect(apiRes.json).toHaveBeenCalledWith({ error: "Rota não encontrada." });
96+
expect(apiRes.sendFile).not.toHaveBeenCalled();
97+
98+
const pageRes = {
99+
status: vi.fn().mockReturnThis(),
100+
json: vi.fn(),
101+
sendFile: vi.fn(),
102+
};
103+
fallbackHandler({ path: "/dashboard" }, pageRes);
104+
expect(pageRes.sendFile).toHaveBeenCalledWith(path.join("/tmp/frontend-dist", "index.html"));
105+
expect(pageRes.status).not.toHaveBeenCalled();
106+
});
44107
});

electron/loading.html

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<!DOCTYPE html>
2+
<html lang="pt-BR">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<!--
6+
Strict CSP: no inline scripts, no external resources.
7+
All styling is inline CSS only (allowed by 'unsafe-inline' for style-src).
8+
-->
9+
<meta http-equiv="Content-Security-Policy"
10+
content="default-src 'none'; style-src 'unsafe-inline'" />
11+
<title>Vagas Full – Carregando</title>
12+
<style>
13+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
14+
15+
body {
16+
display: flex;
17+
align-items: center;
18+
justify-content: center;
19+
height: 100vh;
20+
background: #0f172a; /* slate-900 */
21+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
22+
color: #f1f5f9; /* slate-100 */
23+
user-select: none;
24+
-webkit-app-region: drag; /* makes frameless window draggable */
25+
}
26+
27+
.card {
28+
display: flex;
29+
flex-direction: column;
30+
align-items: center;
31+
gap: 20px;
32+
padding: 40px 48px;
33+
background: #1e293b; /* slate-800 */
34+
border: 1px solid #334155; /* slate-700 */
35+
border-radius: 16px;
36+
width: 420px;
37+
text-align: center;
38+
}
39+
40+
.logo {
41+
font-size: 26px;
42+
font-weight: 700;
43+
letter-spacing: -0.5px;
44+
color: #38bdf8; /* sky-400 */
45+
}
46+
47+
.spinner {
48+
width: 44px;
49+
height: 44px;
50+
border: 4px solid #334155;
51+
border-top-color: #38bdf8; /* sky-400 */
52+
border-radius: 50%;
53+
animation: spin 0.85s linear infinite;
54+
}
55+
56+
@keyframes spin {
57+
to { transform: rotate(360deg); }
58+
}
59+
60+
.status {
61+
font-size: 16px;
62+
font-weight: 600;
63+
color: #e2e8f0; /* slate-200 */
64+
}
65+
66+
.hint {
67+
font-size: 13px;
68+
color: #64748b; /* slate-500 */
69+
line-height: 1.5;
70+
}
71+
</style>
72+
</head>
73+
<body>
74+
<div class="card">
75+
<div class="logo">Vagas Full</div>
76+
<div class="spinner"></div>
77+
<p class="status">Buscando vagas no LinkedIn...</p>
78+
<p class="hint">Isso pode levar alguns instantes.<br>Aguarde enquanto os dados são coletados.</p>
79+
</div>
80+
</body>
81+
</html>

0 commit comments

Comments
 (0)