Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 3 additions & 1 deletion .github/workflows/core-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ concurrency:

jobs:
compatibility:
runs-on: ubuntu-latest
# Playwright's bundled Chromium does not yet support ubuntu-26.04, which
# ubuntu-latest now resolves to; pin to 24.04 until Playwright catches up.
runs-on: ubuntu-24.04
timeout-minutes: 120
env:
CI: "true"
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ jobs:
run: ./test --core ../core quick

browser:
runs-on: ubuntu-latest
# Playwright's bundled Chromium does not yet support ubuntu-26.04, which
# ubuntu-latest now resolves to; pin to 24.04 until Playwright catches up.
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
CI: "true"
Expand Down
2 changes: 1 addition & 1 deletion core.ref
Original file line number Diff line number Diff line change
@@ -1 +1 @@
240a7d59bbe3a0929b117d8cfc10149beeb168ab
0ebcef99eac93a2e7b0db03dd39b0fff519ac0c1
17 changes: 11 additions & 6 deletions e2e/fixtures/api-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,8 @@ export async function listCustomFieldsViaAPI(request: APIRequestContext) {
}

/**
* Create a custom field via the API (bypasses UI for faster test setup)
* Create a custom field via the API (bypasses UI for faster test setup).
* Uses the v2 contract; the response wraps the field in `custom_field`.
*/
export async function createCustomFieldViaAPI(
request: APIRequestContext,
Expand All @@ -328,26 +329,30 @@ export async function createCustomFieldViaAPI(
},
) {
const { field_name, ...rest } = data;
const response = await request.post(`${BASE_URL}/api/admin/custom-fields`, {
const response = await request.post(`${BASE_URL}/api/v2/custom-fields`, {
headers: defaultHeaders,
data: {
...rest,
name: data.name ?? field_name,
},
});
expect(response.ok()).toBeTruthy();
return response.json();
expect(
response.ok(),
`create custom field failed (${response.status()}): ${await response.text()}`,
).toBeTruthy();
const body = await response.json();
return body.data?.custom_field ?? body.data ?? body;
}

/**
* Delete a custom field via the API
* Delete a custom field via the API (v2).
*/
export async function deleteCustomFieldViaAPI(
request: APIRequestContext,
fieldId: number,
) {
const response = await request.delete(
`${BASE_URL}/api/admin/custom-fields/${fieldId}`,
`${BASE_URL}/api/v2/custom-fields/${fieldId}`,
{
headers: defaultHeaders,
},
Expand Down
42 changes: 42 additions & 0 deletions e2e/fixtures/global-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { mkdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

// Playwright's serial mode only orders tests inside one file, so specs that
// mutate a global singleton (the active theme) can still run concurrently in
// separate workers. Coordinate them with a directory that only one worker can
// create; the holder removes it on release.
const LOCK_ROOT = join(tmpdir(), "windshift-e2e-locks");
const ACQUIRE_TIMEOUT_MS = 90_000;
const STALE_LOCK_MS = 300_000;
const RETRY_MS = 100;

/** Acquire an exclusive lock shared by every worker on this machine. */
export async function acquireGlobalLock(name: string): Promise<() => Promise<void>> {
const path = join(LOCK_ROOT, name);
await mkdir(LOCK_ROOT, { recursive: true });
const deadline = Date.now() + ACQUIRE_TIMEOUT_MS;

for (;;) {
try {
await mkdir(path);
return async () => {
await rm(path, { recursive: true, force: true });
};
} catch (error) {
if ((error as { code?: string }).code !== "EEXIST") throw error;
}

// A worker that crashed mid-test leaves the directory behind; only break
// it once it is older than any single test could reasonably hold it.
const info = await stat(path).catch(() => null);
if (info && Date.now() - info.mtimeMs > STALE_LOCK_MS) {
await rm(path, { recursive: true, force: true });
continue;
}
if (Date.now() > deadline) {
throw new Error(`Timed out waiting for the "${name}" global test lock`);
}
await new Promise((resolve) => setTimeout(resolve, RETRY_MS));
}
}
154 changes: 129 additions & 25 deletions e2e/fixtures/plugin-zip.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { type APIRequestContext, expect } from "./context-path";

const BASE_URL = process.env.BASE_URL || "http://localhost:8080";
Expand Down Expand Up @@ -126,22 +128,12 @@ export async function installCapabilityPlugin(
name: string,
capabilities: string[],
): Promise<void> {
const zip = capabilityPluginZip(name, capabilities);

const response = await request.post(`${BASE_URL}/api/plugins/upload`, {
headers: { "Sec-Fetch-Site": "same-origin" },
multipart: {
plugin: {
name: `${name}.zip`,
mimeType: "application/zip",
buffer: Buffer.from(zip),
},
},
await uploadStubPlugin(request, {
name,
version: "1.0.0",
entryPoint: "plugin.wasm",
capabilities,
});
expect(
response.ok(),
`plugin upload failed (${response.status()}): ${await response.text()}`,
).toBeTruthy();
}

/**
Expand Down Expand Up @@ -171,18 +163,130 @@ export async function deleteCapabilityPlugins(
}
}

function capabilityPluginZip(name: string, capabilities: string[]): Buffer {
const manifest = JSON.stringify({
function stubWasm(): Uint8Array {
return Uint8Array.from(atob(MINIMAL_WASM_BASE64), (c) => c.charCodeAt(0));
}

/**
* Upload a plugin whose manifest declares routes with explicit auth so the
* module settings page can render the per-route badge.
*/
export async function installRouteAuthPlugin(
request: APIRequestContext,
name: string,
routes: Array<{ method?: string; path: string; auth?: string; description?: string }>,
): Promise<void> {
await uploadStubPlugin(request, {
name,
version: "1.0.0",
entryPoint: "plugin.wasm",
capabilities,
routes,
});
const wasm = Uint8Array.from(atob(MINIMAL_WASM_BASE64), (c) => c.charCodeAt(0));
return Buffer.from(
buildZip([
{ name: "manifest.json", data: new TextEncoder().encode(manifest) },
{ name: "plugin.wasm", data: wasm },
]),
);
}

/**
* Upload a plugin whose manifest declares host-function permissions so the
* module settings page can render the requested/granted badge per permission.
*/
export async function installPermissionPlugin(
request: APIRequestContext,
name: string,
permissions: string[],
): Promise<void> {
await uploadStubPlugin(request, {
name,
version: "1.0.0",
entryPoint: "plugin.wasm",
permissions,
});
}

// uploadStubPlugin packs a manifest plus the stub wasm and POSTs it to the admin
// upload endpoint, which every capability/route/permission fixture shares.
async function uploadStubPlugin(
request: APIRequestContext,
manifest: Record<string, unknown>,
): Promise<void> {
const name = String(manifest.name);
const zip = buildZip([
{ name: "manifest.json", data: new TextEncoder().encode(JSON.stringify(manifest)) },
{ name: "plugin.wasm", data: stubWasm() },
]);
await uploadZip(request, `${name}.zip`, zip);
}

/**
* Upload the real editor.markdown plugin from the core checkout so the spec
* exercises the shipped editor.html/editor.js, not a copy. The plugin is
* frontend-only, so the shared stub stands in for the WASM entry point.
*/
export async function installEditorPluginFromCore(
request: APIRequestContext,
coreDir: string,
pluginName: string,
): Promise<void> {
const root = path.join(coreDir, "plugin-src", pluginName);
const manifest = JSON.parse(
fs.readFileSync(path.join(root, "manifest.json"), "utf8"),
) as Record<string, unknown>;
const encoder = new TextEncoder();
const entries: ZipEntry[] = [
{ name: "manifest.json", data: encoder.encode(JSON.stringify(manifest)) },
{ name: "plugin.wasm", data: stubWasm() },
];
for (const asset of ["editor.html", "editor.js", "editor.css"]) {
entries.push({
name: `assets/${asset}`,
data: new Uint8Array(fs.readFileSync(path.join(root, "assets", asset))),
});
}
await uploadZip(request, `${pluginName}.zip`, buildZip(entries));
}

/**
* Upload a plugin that declares per-prefix asset auth policies, plus the given
* assets (paths relative to the plugin's assets directory). Used by the asset
* auth browser spec.
*/
export async function installAssetPlugin(
request: APIRequestContext,
name: string,
policies: Array<{ path: string; auth: string }>,
assets: Record<string, string>,
): Promise<void> {
const encoder = new TextEncoder();
const entries: ZipEntry[] = [
{
name: "manifest.json",
data: encoder.encode(
JSON.stringify({ name, version: "1.0.0", entryPoint: "plugin.wasm", assets: policies }),
),
},
{ name: "plugin.wasm", data: stubWasm() },
];
for (const [assetPath, content] of Object.entries(assets)) {
entries.push({ name: `assets/${assetPath}`, data: encoder.encode(content) });
}
await uploadZip(request, `${name}.zip`, buildZip(entries));
}

async function uploadZip(
request: APIRequestContext,
filename: string,
zip: Uint8Array,
): Promise<void> {
const response = await request.post(`${BASE_URL}/api/plugins/upload`, {
headers: { "Sec-Fetch-Site": "same-origin" },
multipart: {
plugin: {
name: filename,
mimeType: "application/zip",
buffer: Buffer.from(zip),
},
},
});
expect(
response.ok(),
`plugin upload failed (${response.status()}): ${await response.text()}`,
).toBeTruthy();
}
7 changes: 0 additions & 7 deletions e2e/fixtures/test-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,13 +420,6 @@ export function generatePriority(suffix?: string): TestPriority {
};
}

/**
* Wait helper for animations or async operations
*/
export async function waitFor(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Generate random string of specified length
*/
Expand Down
Loading
Loading