mirror of
https://github.com/penpot/penpot.git
synced 2026-08-06 04:48:39 +00:00
✨ Add Node.js E2E API tests for backend (#10787)
Add end-to-end HTTP tests under backend/test/e2e/ using Node.js built-in test runner (node:test) and native fetch. Tests run through the devenv nginx proxy on port 3450. Test suites (19 tests total): - auth-flow: demo profile creation, login, session cookies, access tokens - export-binfile: file creation, export to asset URL via SSE - asset-download: download with cookie/token auth, 401 without auth, S3 redirect behavior, full export-to-download flow Key findings documented in tests: - nginx @handle_redirect intercepts backend 307 and proxies to S3 directly, stripping the client Authorization header (bug does not reproduce in devenv) - SSE end event uses ~#uri tagged format for URLs - Unauthenticated RPC returns uuid/zero profile (not null) AI-assisted-by: mimo-v2.5-pro Signed-off-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
parent
aeedb96260
commit
636bc22cc4
@ -10,6 +10,7 @@
|
||||
"url": "https://github.com/penpot/penpot"
|
||||
},
|
||||
"dependencies": {
|
||||
"eventsource-parser": "^3.0.6",
|
||||
"luxon": "^3.7.2",
|
||||
"sax": "^1.6.1"
|
||||
},
|
||||
@ -21,6 +22,7 @@
|
||||
"scripts": {
|
||||
"lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/",
|
||||
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
|
||||
"fmt:clj": "cljfmt fix --parallel=true src/ test/"
|
||||
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
|
||||
"test:e2e": "node --test --test-concurrency=1 test/e2e/*.test.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
9
backend/pnpm-lock.yaml
generated
9
backend/pnpm-lock.yaml
generated
@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
eventsource-parser:
|
||||
specifier: ^3.0.6
|
||||
version: 3.1.0
|
||||
luxon:
|
||||
specifier: ^3.7.2
|
||||
version: 3.7.2
|
||||
@ -63,6 +66,10 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
eventsource-parser@3.1.0:
|
||||
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
fill-range@7.1.1:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
@ -216,6 +223,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
supports-color: 5.5.0
|
||||
|
||||
eventsource-parser@3.1.0: {}
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
145
backend/test/e2e/asset-download.test.mjs
Normal file
145
backend/test/e2e/asset-download.test.mjs
Normal file
@ -0,0 +1,145 @@
|
||||
import { describe, it, before } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
setupTestProfile,
|
||||
createAccessToken,
|
||||
} from "./helpers/auth.mjs";
|
||||
import { rpcPost, getAsset } from "./helpers/client.mjs";
|
||||
import { parseSSE, extractResult } from "./helpers/sse.mjs";
|
||||
|
||||
async function createAndExport(cookie, projectId) {
|
||||
const createRes = await rpcPost(
|
||||
"create-file",
|
||||
{ name: "E2E Asset Test", projectId },
|
||||
{ cookieToken: cookie }
|
||||
);
|
||||
assert.equal(createRes.status, 200);
|
||||
const fileId = createRes.body.id;
|
||||
|
||||
const exportRes = await rpcPost(
|
||||
"export-binfile",
|
||||
{ fileId, includeLibraries: false, embedAssets: true },
|
||||
{ cookieToken: cookie }
|
||||
);
|
||||
assert.equal(exportRes.status, 200);
|
||||
const assetUrl = extractResult(parseSSE(exportRes.body));
|
||||
return assetUrl;
|
||||
}
|
||||
|
||||
function extractAssetId(assetUrl) {
|
||||
const match = assetUrl.match(/\/assets\/by-id\/([0-9a-f-]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
describe("asset download", () => {
|
||||
let profile, cookie, assetUrl, assetId;
|
||||
|
||||
before(async () => {
|
||||
const setup = await setupTestProfile();
|
||||
profile = setup.profile;
|
||||
cookie = setup.cookie;
|
||||
|
||||
assetUrl = await createAndExport(cookie, profile.defaultProjectId);
|
||||
assetId = extractAssetId(assetUrl);
|
||||
assert.ok(assetId, `should extract asset id from URL: ${assetUrl}`);
|
||||
});
|
||||
|
||||
it("asset download with cookie auth succeeds", async () => {
|
||||
// In devenv, nginx's @handle_redirect intercepts the backend's 307 and
|
||||
// proxies to S3 directly. The client sees 200 with file content, not 307.
|
||||
const res = await getAsset(assetId, { cookieToken: cookie });
|
||||
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
|
||||
assert.ok(
|
||||
res.body.length > 0 || typeof res.body === "object",
|
||||
"response should have content"
|
||||
);
|
||||
});
|
||||
|
||||
it("asset download with access token auth succeeds", async () => {
|
||||
const tokenObj = await createAccessToken(cookie, "e2e-asset-test");
|
||||
const accessToken = tokenObj.token;
|
||||
|
||||
const res = await getAsset(assetId, { accessToken });
|
||||
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
|
||||
});
|
||||
|
||||
it("asset download without auth returns 401", async () => {
|
||||
const res = await getAsset(assetId, {});
|
||||
assert.equal(res.status, 401, `expected 401, got ${res.status}`);
|
||||
});
|
||||
|
||||
it("asset download returns file content through nginx proxy", async () => {
|
||||
// The full flow: backend returns 307 with S3 presigned URL,
|
||||
// nginx intercepts and proxies to S3, client gets 200 with content.
|
||||
const res = await getAsset(assetId, { cookieToken: cookie });
|
||||
assert.equal(res.status, 200);
|
||||
// Response should be a .penpot file (binary/zip content)
|
||||
assert.ok(res.body, "response should have body");
|
||||
});
|
||||
|
||||
it("follow S3 redirect WITH auth header (bug repro)", async () => {
|
||||
// In devenv, nginx's @handle_redirect intercepts the 307 and proxies to
|
||||
// S3 server-side, only forwarding the Host header from X-Host. The client's
|
||||
// Authorization header is NOT forwarded to S3, so the request succeeds.
|
||||
//
|
||||
// In production (no nginx proxy), the backend returns 307 directly. The HTTP
|
||||
// client follows the redirect and forwards the Authorization: Token header to
|
||||
// S3, which conflicts with the presigned URL's X-Amz-* params and returns
|
||||
// 400 InvalidArgument.
|
||||
//
|
||||
// This test documents the devenv behavior: nginx strips the auth header
|
||||
// when proxying to S3, so the download succeeds.
|
||||
const res = await getAsset(assetId, { cookieToken: cookie });
|
||||
assert.equal(res.status, 200, "through nginx, download succeeds");
|
||||
assert.ok(res.body, "should have file content");
|
||||
});
|
||||
|
||||
it("full export-to-download flow works end-to-end", async () => {
|
||||
const url = await createAndExport(cookie, profile.defaultProjectId);
|
||||
const id = extractAssetId(url);
|
||||
assert.ok(id);
|
||||
|
||||
const res = await getAsset(id, { cookieToken: cookie });
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
it("asset URL is accessible immediately after export", async () => {
|
||||
const url = await createAndExport(cookie, profile.defaultProjectId);
|
||||
const id = extractAssetId(url);
|
||||
assert.ok(id);
|
||||
|
||||
const res = await getAsset(id, { cookieToken: cookie });
|
||||
assert.equal(res.status, 200, "asset should be accessible right after export");
|
||||
});
|
||||
|
||||
it("token-only: export then download asset with same token", async () => {
|
||||
const tokenObj = await createAccessToken(cookie, "e2e-token-export-test");
|
||||
const token = tokenObj.token;
|
||||
|
||||
const createRes = await rpcPost(
|
||||
"create-file",
|
||||
{ name: "E2E Token Export Test", projectId: profile.defaultProjectId },
|
||||
{ accessToken: token }
|
||||
);
|
||||
assert.equal(createRes.status, 200);
|
||||
const fileId = createRes.body.id;
|
||||
|
||||
const exportRes = await rpcPost(
|
||||
"export-binfile",
|
||||
{ fileId, includeLibraries: false, embedAssets: true },
|
||||
{ accessToken: token }
|
||||
);
|
||||
assert.equal(exportRes.status, 200);
|
||||
|
||||
const events = parseSSE(exportRes.body);
|
||||
const url = extractResult(events);
|
||||
assert.ok(url, "should get an asset URL from export");
|
||||
|
||||
const id = extractAssetId(url);
|
||||
assert.ok(id, `should extract asset id from URL: ${url}`);
|
||||
|
||||
const res = await getAsset(id, { accessToken: token });
|
||||
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
|
||||
assert.ok(res.body, "response should have file content");
|
||||
});
|
||||
});
|
||||
78
backend/test/e2e/auth-flow.test.mjs
Normal file
78
backend/test/e2e/auth-flow.test.mjs
Normal file
@ -0,0 +1,78 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
createDemoProfile,
|
||||
login,
|
||||
setupTestProfile,
|
||||
} from "./helpers/auth.mjs";
|
||||
import { rpcPost } from "./helpers/client.mjs";
|
||||
|
||||
describe("auth flow", () => {
|
||||
it("creates a demo profile", async () => {
|
||||
const { email, password } = await createDemoProfile();
|
||||
assert.match(email, /^demo-.*\.demo@example\.com$/);
|
||||
assert.ok(password.length > 0);
|
||||
});
|
||||
|
||||
it("logs in with valid credentials", async () => {
|
||||
const { email, password } = await createDemoProfile();
|
||||
const { profile, cookie } = await login(email, password);
|
||||
|
||||
assert.equal(profile.email, email);
|
||||
assert.equal(profile.isDemo, true);
|
||||
assert.ok(profile.id, "profile should have id");
|
||||
assert.ok(profile.defaultProjectId, "profile should have defaultProjectId");
|
||||
assert.ok(profile.defaultTeamId, "profile should have defaultTeamId");
|
||||
assert.ok(cookie, "cookie should be set");
|
||||
});
|
||||
|
||||
it("login sets session cookie", async () => {
|
||||
const { email, password } = await createDemoProfile();
|
||||
const { cookie } = await login(email, password);
|
||||
assert.ok(cookie, "auth-token cookie should be extracted");
|
||||
assert.ok(cookie.length > 10, "cookie should have meaningful length");
|
||||
});
|
||||
|
||||
it("login fails with wrong password", async () => {
|
||||
const { email } = await createDemoProfile();
|
||||
try {
|
||||
await login(email, "wrong-password");
|
||||
assert.fail("should have thrown");
|
||||
} catch (e) {
|
||||
assert.ok(e.message.includes("Login failed"));
|
||||
}
|
||||
});
|
||||
|
||||
it("login fails with non-existent email", async () => {
|
||||
try {
|
||||
await login("nonexistent@example.com", "some-password");
|
||||
assert.fail("should have thrown");
|
||||
} catch (e) {
|
||||
assert.ok(e.message.includes("Login failed"));
|
||||
}
|
||||
});
|
||||
|
||||
it("authenticated RPC with cookie", async () => {
|
||||
const { profile, cookie } = await setupTestProfile();
|
||||
const res = await rpcPost("get-profile", {}, { cookieToken: cookie });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.id, profile.id);
|
||||
assert.equal(res.body.email, profile.email);
|
||||
});
|
||||
|
||||
it("unauthenticated RPC returns anonymous profile", async () => {
|
||||
const res = await rpcPost("get-profile", {});
|
||||
assert.equal(res.status, 200);
|
||||
// Anonymous profile has uuid/zero as id
|
||||
assert.equal(res.body.id, "00000000-0000-0000-0000-000000000000");
|
||||
});
|
||||
|
||||
it("setupTestProfile returns all fields", async () => {
|
||||
const { profile, cookie, email, password } = await setupTestProfile();
|
||||
assert.ok(profile.id);
|
||||
assert.ok(profile.defaultProjectId);
|
||||
assert.ok(cookie);
|
||||
assert.ok(email);
|
||||
assert.ok(password);
|
||||
});
|
||||
});
|
||||
7
backend/test/e2e/config.mjs
Normal file
7
backend/test/e2e/config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = Object.freeze({
|
||||
baseUrl: process.env.PENPOT_BASE_URL || "http://localhost:3450",
|
||||
email: process.env.PENPOT_EMAIL || null,
|
||||
password: process.env.PENPOT_PASSWORD || null,
|
||||
});
|
||||
|
||||
export default config;
|
||||
87
backend/test/e2e/export-binfile.test.mjs
Normal file
87
backend/test/e2e/export-binfile.test.mjs
Normal file
@ -0,0 +1,87 @@
|
||||
import { describe, it, before } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupTestProfile } from "./helpers/auth.mjs";
|
||||
import { rpcPost } from "./helpers/client.mjs";
|
||||
import { parseSSE, extractResult } from "./helpers/sse.mjs";
|
||||
|
||||
async function createFile(cookie, projectId, name = "E2E Test File") {
|
||||
const res = await rpcPost(
|
||||
"create-file",
|
||||
{ name, projectId },
|
||||
{ cookieToken: cookie }
|
||||
);
|
||||
assert.equal(res.status, 200, `create-file failed: ${JSON.stringify(res.body)}`);
|
||||
return res.body;
|
||||
}
|
||||
|
||||
async function exportFile(cookie, fileId) {
|
||||
const res = await rpcPost(
|
||||
"export-binfile",
|
||||
{
|
||||
fileId,
|
||||
includeLibraries: false,
|
||||
embedAssets: true,
|
||||
},
|
||||
{ cookieToken: cookie }
|
||||
);
|
||||
assert.equal(res.status, 200, `export-binfile failed: ${JSON.stringify(res.body)}`);
|
||||
|
||||
const events = parseSSE(res.body);
|
||||
const assetUrl = extractResult(events);
|
||||
return assetUrl;
|
||||
}
|
||||
|
||||
describe("export-binfile", () => {
|
||||
let profile, cookie;
|
||||
|
||||
before(async () => {
|
||||
const setup = await setupTestProfile();
|
||||
profile = setup.profile;
|
||||
cookie = setup.cookie;
|
||||
});
|
||||
|
||||
it("creates a file via API", async () => {
|
||||
const file = await createFile(cookie, profile.defaultProjectId);
|
||||
assert.ok(file.id, "file should have an id");
|
||||
assert.equal(file.name, "E2E Test File");
|
||||
});
|
||||
|
||||
it("export returns an asset URL", async () => {
|
||||
const file = await createFile(cookie, profile.defaultProjectId);
|
||||
const assetUrl = await exportFile(cookie, file.id);
|
||||
assert.ok(
|
||||
typeof assetUrl === "string" && assetUrl.includes("/assets/by-id/"),
|
||||
`asset URL should contain /assets/by-id/, got: ${assetUrl}`
|
||||
);
|
||||
assert.match(assetUrl, /\/assets\/by-id\/[0-9a-f-]+$/);
|
||||
});
|
||||
|
||||
it("export with invalid file-id returns error", async () => {
|
||||
const fakeId = "00000000-0000-0000-0000-000000000000";
|
||||
const res = await rpcPost(
|
||||
"export-binfile",
|
||||
{
|
||||
fileId: fakeId,
|
||||
includeLibraries: false,
|
||||
embedAssets: true,
|
||||
},
|
||||
{ cookieToken: cookie }
|
||||
);
|
||||
assert.ok(
|
||||
res.body.type || res.status !== 200,
|
||||
"should return error for non-existent file"
|
||||
);
|
||||
});
|
||||
|
||||
it("export requires authentication", async () => {
|
||||
const res = await rpcPost("export-binfile", {
|
||||
fileId: "00000000-0000-0000-0000-000000000000",
|
||||
includeLibraries: false,
|
||||
embedAssets: true,
|
||||
});
|
||||
assert.ok(
|
||||
res.body.type || res.status !== 200,
|
||||
"should require authentication"
|
||||
);
|
||||
});
|
||||
});
|
||||
38
backend/test/e2e/helpers/auth.mjs
Normal file
38
backend/test/e2e/helpers/auth.mjs
Normal file
@ -0,0 +1,38 @@
|
||||
import { rpcPost, extractCookie } from "./client.mjs";
|
||||
|
||||
export async function createDemoProfile() {
|
||||
const res = await rpcPost("create-demo-profile", {});
|
||||
if (res.body.type === "validation" || res.body.type === "restriction") {
|
||||
throw new Error(
|
||||
`Failed to create demo profile: ${res.body.code} - ${res.body.hint || ""}`
|
||||
);
|
||||
}
|
||||
return { email: res.body.email, password: res.body.password };
|
||||
}
|
||||
|
||||
export async function login(email, password) {
|
||||
const res = await rpcPost("login-with-password", { email, password });
|
||||
if (res.status !== 200 || res.body.type) {
|
||||
throw new Error(
|
||||
`Login failed: ${JSON.stringify(res.body)}`
|
||||
);
|
||||
}
|
||||
const cookie = extractCookie(res.setCookie);
|
||||
return { profile: res.body, cookie };
|
||||
}
|
||||
|
||||
export async function createAccessToken(cookie, name = "e2e-test-token") {
|
||||
const res = await rpcPost("create-access-token", { name }, { cookieToken: cookie });
|
||||
if (res.status !== 200 || res.body.type) {
|
||||
throw new Error(
|
||||
`Create access token failed: ${JSON.stringify(res.body)}`
|
||||
);
|
||||
}
|
||||
return res.body;
|
||||
}
|
||||
|
||||
export async function setupTestProfile() {
|
||||
const { email, password } = await createDemoProfile();
|
||||
const { profile, cookie } = await login(email, password);
|
||||
return { profile, cookie, email, password };
|
||||
}
|
||||
86
backend/test/e2e/helpers/client.mjs
Normal file
86
backend/test/e2e/helpers/client.mjs
Normal file
@ -0,0 +1,86 @@
|
||||
import config from "../config.mjs";
|
||||
|
||||
async function parseResponse(response) {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const setCookie = response.headers.get("set-cookie") || null;
|
||||
|
||||
let body;
|
||||
if (contentType.includes("application/json")) {
|
||||
body = await response.json();
|
||||
} else {
|
||||
body = await response.text();
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
body,
|
||||
setCookie,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractCookie(setCookieHeader, name = "auth-token") {
|
||||
if (!setCookieHeader) return null;
|
||||
const match = setCookieHeader.match(new RegExp(`${name}=([^;]+)`));
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export async function rpcPost(method, body = {}, { cookieToken, accessToken } = {}) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (cookieToken) {
|
||||
headers.Cookie = `auth-token=${cookieToken}`;
|
||||
}
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Token ${accessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${config.baseUrl}/api/main/methods/${method}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function multipartPost(method, formData, { cookieToken } = {}) {
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (cookieToken) {
|
||||
headers.Cookie = `auth-token=${cookieToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${config.baseUrl}/api/main/methods/${method}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function getAsset(
|
||||
id,
|
||||
{ cookieToken, accessToken, redirect = "manual" } = {}
|
||||
) {
|
||||
const headers = { Accept: "application/json" };
|
||||
if (cookieToken) {
|
||||
headers.Cookie = `auth-token=${cookieToken}`;
|
||||
}
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Token ${accessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${config.baseUrl}/assets/by-id/${id}`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect,
|
||||
});
|
||||
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
72
backend/test/e2e/helpers/sse.mjs
Normal file
72
backend/test/e2e/helpers/sse.mjs
Normal file
@ -0,0 +1,72 @@
|
||||
import { createParser } from "eventsource-parser";
|
||||
|
||||
export function parseSSE(text) {
|
||||
const events = [];
|
||||
const parser = createParser({
|
||||
onEvent(event) {
|
||||
events.push({ event: event.event || "message", data: event.data });
|
||||
},
|
||||
});
|
||||
parser.feed(text);
|
||||
return events;
|
||||
}
|
||||
|
||||
export function extractResult(events) {
|
||||
const endEvent = events.find((e) => e.event === "end");
|
||||
if (!endEvent) {
|
||||
const errEvent = events.find((e) => e.event === "error");
|
||||
if (errEvent) {
|
||||
throw new Error(`SSE error: ${errEvent.data}`);
|
||||
}
|
||||
throw new Error(`No end event found in SSE stream. Events: ${JSON.stringify(events)}`);
|
||||
}
|
||||
|
||||
const raw = JSON.parse(endEvent.data);
|
||||
|
||||
// Transit JSON verbose format:
|
||||
// For URIs (e.g. asset URL): {"~#uri":"https://..."}
|
||||
// For objects: {"~:key":"val",...} or ["^ ","~:key","val",...]
|
||||
// For strings: plain string
|
||||
if (raw && typeof raw === "object") {
|
||||
// Tagged URI
|
||||
if ("~#uri" in raw) {
|
||||
return raw["~#uri"];
|
||||
}
|
||||
// Transit map with ~:value key
|
||||
if ("~:value" in raw) {
|
||||
const value = raw["~:value"];
|
||||
if (Array.isArray(value)) {
|
||||
return transitArrayToObj(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
// Direct transit map (keys starting with ~:)
|
||||
const firstKey = Object.keys(raw)[0];
|
||||
if (firstKey && firstKey.startsWith("~:")) {
|
||||
return transitMapToObj(raw);
|
||||
}
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
function transitArrayToObj(arr) {
|
||||
// Transit verbose object: ["^ ","~:key1","val1","~:key2","val2",...]
|
||||
const obj = {};
|
||||
for (let i = 1; i < arr.length; i += 2) {
|
||||
const key = arr[i].replace(/^~:/, "");
|
||||
const val = arr[i + 1];
|
||||
obj[key] = val;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function transitMapToObj(map) {
|
||||
// Transit verbose map: {"~:key1":"val1","~:key2":"val2",...}
|
||||
const obj = {};
|
||||
for (const [key, val] of Object.entries(map)) {
|
||||
const cleanKey = key.replace(/^~:/, "");
|
||||
obj[cleanKey] = val;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user