test(news): gate deployment on an isolated real browser publication journey
This commit is contained in:
1 parent
60e49c1ec9
commit
7867bf6b72
11 files changed
+980
-2
No files matched your search
@@ -101,6 +101,17 @@ jobs:
|
||||
DEPLOY_BRANCH: ${{ gitea.ref_name }}
|
||||
run: bash scripts/ci-deploy.sh
|
||||
|
||||
- name: Upload isolated news browser results
|
||||
if: always()
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||
with:
|
||||
name: news-browser-results
|
||||
path: |
|
||||
test-results/news-real/
|
||||
playwright-report/news-real/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload JavaScript size report
|
||||
if: always()
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Real news browser gate
|
||||
|
||||
Run `pnpm test:news:real` with `NEWS_E2E_IMAGE=epicnext-cms:<candidate-tag>`. Docker CLI, a working Docker daemon, the OpenSSL CLI with `-addext` support, installed project dependencies and Playwright Chromium are required. The runner deliberately fails when the image or Docker is unavailable. It never silently skips the browser journey.
|
||||
|
||||
`NEWS_E2E_RELEASE=<full-commit-sha>` additionally verifies the image revision label. The runner resolves the local image to its immutable ID before starting it. It does not build or pull the application image. MariaDB 11.4.5 and Redis 7.4.2 Alpine are disposable Testcontainers dependencies and may be pulled when absent.
|
||||
|
||||
The deployment script runs this gate after building the candidate and extracting its performance report, before live database migrations and container cutover. The general Playwright suite excludes `e2e/news-real`; this suite has its own configuration and requires the runner.
|
||||
|
||||
## What the browser proves
|
||||
|
||||
One Chromium journey exercises the candidate's normal Docker entrypoint and standalone Next server:
|
||||
|
||||
1. An anonymous request to the staff editor reaches the login page.
|
||||
2. The browser signs in through the actual login form, credential precheck and Auth.js handler. The test verifies the real Secure/HttpOnly session cookie and database login record.
|
||||
3. A rank 7 editor, below an occupied rank 9 owner, accesses news through three explicit ACL grants: `admin.dashboard`, `admin.news.view` and `admin.news.edit`.
|
||||
4. The browser types Unicode content into the bundled TinyMCE editor and saves a draft through the real server action. The persisted HTML must equal what the form submitted.
|
||||
5. An independent anonymous browser sees the not-found screen; the public articles API returns an empty list. Redis contains the cached null article. Next can stream a not-found screen with HTTP 200, so this check verifies the rendered 404 screen as well as absence from the API.
|
||||
6. The editor reopens and previews the saved draft in the real preview iframe. Its title, summary and rendered body match; it remains a draft with no article revision created by previewing.
|
||||
7. Publishing through the editor produces one article, a publication timestamp, one previous-draft revision, a before/after audit entry, two completed operation results and two news-refresh outbox entries.
|
||||
8. The anonymous page and API immediately show the article, using a new shared Redis cache revision. The browser remains signed out.
|
||||
|
||||
No authentication, application HTTP responses, mutations, database calls or cache calls are mocked. The browser blocks resources outside the local fixture origin, such as external avatars. This does not replace any application response. A local HTTPS edge passes requests to Next and sets trusted forwarding headers, allowing the production Secure-cookie behavior to run normally.
|
||||
|
||||
## Isolation and cleanup
|
||||
|
||||
- Each run creates a random Docker network and fresh MariaDB, Redis and candidate containers. All mapped ports are allocated dynamically. The HTTPS listener binds only to `127.0.0.1`.
|
||||
- No production container, database, volume or credentials are reused. Container configuration is explicit. Child processes receive a small environment whitelist; checkout `.env` and installation service credentials are not forwarded.
|
||||
- The database uses the current ORM column definitions for 29 tables needed by login, site/admin layouts and news. Unique constraints and composite primary keys are preserved. The emulator-owned `permission_ranks` fixture provides the rank authority columns this flow queries. Real CMS migrations 0026 and 0031 create the recovery/revision and operation/outbox tables. This is a focused fixture, not a replacement for the emulator's complete schema or migration coverage.
|
||||
- Passwords, Redis credentials and the Auth.js secret are generated per run. The owner has an unknown random password and is never used to bypass ACL checks. Email verification is enabled with a verified fixture staff account. CAPTCHA and forced staff 2FA use their ordinary disabled installation settings; their challenges are outside this flow.
|
||||
- OpenSSL generates a fresh localhost certificate and private key in the OS temporary directory for each run. The certificate lasts one day and covers `127.0.0.1` and `localhost`. Playwright trusts this self-signed endpoint only in this suite. No certificate or key is committed or copied into build artifacts; cleanup removes both with the temporary credentials.
|
||||
- Credentials used by the test worker are stored in an OS temporary directory with a mode-0600 file, then removed. Cleanup stops only containers and the network created by this runner; Testcontainers also registers them with its resource reaper.
|
||||
- Failure artifacts are under `test-results/news-real` and `playwright-report/news-real`. Server logs redact generated passwords/secrets. Playwright traces can contain the short-lived fixture login/session data; all associated services are destroyed after the run.
|
||||
|
||||
This browser gate checks the synchronous editor/publication path and durable delivery intent. Scheduler concurrency, rollback, duplicate requests, worker delivery and Redis outage/recovery remain covered by `pnpm test:integration`. It does not claim to test emulator connectivity, external notifications, CAPTCHA/2FA challenges or production data.
|
||||
|
||||
The local workstation currently has no working Docker daemon. Type checks, lint and fixture/bootstrap checks can run there; a passing real browser result must come from the Docker-capable CI gate.
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { type BrowserContext, expect, test } from "@playwright/test";
|
||||
import Redis from "ioredis";
|
||||
import mysql, {
|
||||
type ConnectionOptions,
|
||||
type RowDataPacket,
|
||||
} from "mysql2/promise";
|
||||
|
||||
interface Fixture {
|
||||
username: string;
|
||||
userId: number;
|
||||
password: string;
|
||||
database: ConnectionOptions;
|
||||
redis: { host: string; port: number; password: string };
|
||||
}
|
||||
|
||||
async function onlyLocalResources(context: BrowserContext, origin: string) {
|
||||
// External avatars/telemetry are outside this isolated hotel. All application
|
||||
// documents, scripts, forms, API requests and auth responses remain untouched.
|
||||
await context.route("**/*", (route) => {
|
||||
if (new URL(route.request().url()).origin === origin)
|
||||
return route.continue();
|
||||
return route.abort("blockedbyclient");
|
||||
});
|
||||
}
|
||||
|
||||
test("staff signs in, saves a draft, previews it and publishes to anonymous readers", async ({
|
||||
page,
|
||||
context,
|
||||
browser,
|
||||
baseURL,
|
||||
}, testInfo) => {
|
||||
if (!baseURL || !process.env.NEWS_E2E_FIXTURE)
|
||||
throw Error("Disposable fixture is required");
|
||||
const fixture = JSON.parse(
|
||||
await readFile(process.env.NEWS_E2E_FIXTURE, "utf8"),
|
||||
) as Fixture;
|
||||
const database = await mysql.createConnection({
|
||||
...fixture.database,
|
||||
timezone: "Z",
|
||||
charset: "utf8mb4",
|
||||
supportBigNumbers: true,
|
||||
bigNumberStrings: true,
|
||||
});
|
||||
const redis = new Redis({
|
||||
...fixture.redis,
|
||||
maxRetriesPerRequest: 1,
|
||||
connectTimeout: 5_000,
|
||||
});
|
||||
const anonymous = await browser.newContext({
|
||||
baseURL,
|
||||
locale: "en-US",
|
||||
ignoreHTTPSErrors: true,
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
const reader = await anonymous.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
reader.on("pageerror", (error) => errors.push(error.message));
|
||||
const rows = async (sql: string, params: string[] = []) =>
|
||||
(await database.query<RowDataPacket[]>(sql, params))[0];
|
||||
const title = "Notizia browser: città e novità 🎉";
|
||||
const slug = "browser-news-real";
|
||||
const summary =
|
||||
"Una notizia creata e pubblicata attraverso il pannello reale.";
|
||||
const body = "È una prova reale: caffè, città e 🎉. Salvata dal browser.";
|
||||
let articleId = "";
|
||||
let submittedHtml = "";
|
||||
let draftRevision: string | null = null;
|
||||
try {
|
||||
await onlyLocalResources(context, baseURL);
|
||||
await onlyLocalResources(anonymous, baseURL);
|
||||
await redis.ping();
|
||||
await test.step("real authentication and staff ACL", async () => {
|
||||
await page.goto("/admin/articles/new");
|
||||
await expect(page).toHaveURL(/\/login(?:\?|$)/);
|
||||
expect(await rows("SELECT user_id FROM website_login_logs")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
await page
|
||||
.locator('input[autocomplete="username"]')
|
||||
.fill(fixture.username);
|
||||
await page
|
||||
.locator('input[autocomplete="current-password"]')
|
||||
.fill(fixture.password);
|
||||
await page
|
||||
.locator('input[autocomplete="current-password"]')
|
||||
.press("Enter");
|
||||
await expect(page).toHaveURL(/\/me(?:\?|$)/);
|
||||
const session = await context.request
|
||||
.get("/api/auth/session")
|
||||
.then((response) => response.json());
|
||||
expect(session.user).toMatchObject({
|
||||
id: String(fixture.userId),
|
||||
name: fixture.username,
|
||||
rank: 7,
|
||||
});
|
||||
expect(
|
||||
(await context.cookies()).some(
|
||||
(cookie) =>
|
||||
cookie.name.startsWith("__Secure-authjs.session-token") &&
|
||||
cookie.secure &&
|
||||
cookie.httpOnly,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(await rows("SELECT user_id FROM website_login_logs")).toEqual([
|
||||
expect.objectContaining({ user_id: fixture.userId }),
|
||||
]);
|
||||
// The editor is below the highest occupied rank: access requires real ACL grants.
|
||||
expect(
|
||||
(await rows("SELECT MAX(rank) AS highest FROM users"))[0].highest,
|
||||
).toBe(9);
|
||||
await page.goto("/admin/articles/new");
|
||||
await expect(page.locator('input[name="title"]')).toBeVisible();
|
||||
});
|
||||
await test.step("save the draft using the real rich text editor and server action", async () => {
|
||||
const form = page.locator('form:has(input[name="title"])');
|
||||
await form.locator('input[name="title"]').fill(title);
|
||||
await form.locator('input[name="slug"]').fill(slug);
|
||||
await form.locator('[name="shortStory"]').fill(summary);
|
||||
await form
|
||||
.locator('input[name="image"]')
|
||||
.fill("/assets/images/EnterHubbly.png");
|
||||
await form.locator('select[name="status"]').selectOption("draft");
|
||||
const editor = form
|
||||
.frameLocator("iframe.tox-edit-area__iframe")
|
||||
.locator('body[contenteditable="true"]');
|
||||
await expect(editor).toBeVisible();
|
||||
await editor.fill(body);
|
||||
await editor.press("End");
|
||||
await expect(form.locator('textarea[name="fullStory"]')).toHaveValue(
|
||||
/Salvata dal browser/,
|
||||
);
|
||||
submittedHtml = await form
|
||||
.locator('textarea[name="fullStory"]')
|
||||
.inputValue();
|
||||
await form.locator('button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(`${baseURL}/admin/articles`);
|
||||
const articles = await rows("SELECT * FROM website_articles");
|
||||
expect(articles).toHaveLength(1);
|
||||
expect(articles[0]).toMatchObject({
|
||||
title,
|
||||
slug,
|
||||
short_story: summary,
|
||||
status: "draft",
|
||||
user_id: fixture.userId,
|
||||
published_at: null,
|
||||
publish_at: null,
|
||||
});
|
||||
expect(articles[0].full_story).toBe(submittedHtml);
|
||||
articleId = String(articles[0].id);
|
||||
expect(await rows("SELECT kind, actor_id FROM cms_operations")).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "news.create",
|
||||
actor_id: fixture.userId,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
await test.step("anonymous readers and the shared cache still see no published article", async () => {
|
||||
const response = await reader.goto(`/news/${slug}`);
|
||||
expect(response?.status()).toBeLessThan(500);
|
||||
// Next can stream not-found markup with HTTP 200; assert the actual 404 screen.
|
||||
await expect(reader.locator(".error-screen-code")).toHaveText("404");
|
||||
await expect(
|
||||
reader.getByRole("heading", { name: title, exact: true }),
|
||||
).toHaveCount(0);
|
||||
const listing = await anonymous.request
|
||||
.get("/api/articles")
|
||||
.then((response) => response.json());
|
||||
expect(listing.data).toEqual([]);
|
||||
expect(listing.meta.total).toBe(0);
|
||||
draftRevision = await redis.get("cms:news:revision");
|
||||
expect(draftRevision).not.toBeNull();
|
||||
expect(
|
||||
await redis.get(`news:${draftRevision}:article:v2:slug:${slug}`),
|
||||
).toBe("null");
|
||||
});
|
||||
await test.step("preview the persisted draft without publishing it", async () => {
|
||||
await page
|
||||
.locator(`a[href="/admin/articles/${articleId}"]`)
|
||||
.first()
|
||||
.click();
|
||||
const form = page.locator('form:has(input[name="title"])');
|
||||
await expect(form.locator('input[name="title"]')).toHaveValue(title);
|
||||
await expect(form.locator('select[name="status"]')).toHaveValue("draft");
|
||||
await expect(
|
||||
form.frameLocator("iframe.tox-edit-area__iframe").locator("body"),
|
||||
).toContainText(body);
|
||||
await form.getByRole("button", { name: "Preview", exact: true }).click();
|
||||
const preview = page.getByRole("dialog").frameLocator("iframe");
|
||||
await expect(
|
||||
preview.getByRole("heading", { name: title, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(preview.locator("body")).toContainText(summary);
|
||||
await expect(preview.locator("body")).toContainText(body);
|
||||
expect(await rows("SELECT status FROM website_articles")).toEqual([
|
||||
{ status: "draft" },
|
||||
]);
|
||||
expect(
|
||||
await rows("SELECT id FROM website_article_revisions"),
|
||||
).toHaveLength(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
});
|
||||
await test.step("publish once and persist revision, audit and delivery intent", async () => {
|
||||
const form = page.locator('form:has(input[name="title"])');
|
||||
await form.locator('select[name="status"]').selectOption("published");
|
||||
await form.locator('button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(`${baseURL}/admin/articles`);
|
||||
const articles = await rows("SELECT * FROM website_articles");
|
||||
expect(articles).toHaveLength(1);
|
||||
expect(articles[0]).toMatchObject({
|
||||
status: "published",
|
||||
user_id: fixture.userId,
|
||||
slug,
|
||||
title,
|
||||
});
|
||||
expect(articles[0].published_at).toBeInstanceOf(Date);
|
||||
const revisions = await rows(
|
||||
"SELECT article_id, user_id, payload FROM website_article_revisions",
|
||||
);
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(String(revisions[0].article_id)).toBe(articleId);
|
||||
expect(Number(revisions[0].user_id)).toBe(fixture.userId);
|
||||
expect(JSON.parse(revisions[0].payload)).toMatchObject({
|
||||
title,
|
||||
status: "draft",
|
||||
});
|
||||
const audit = await rows(
|
||||
"SELECT user_id, `before`, `after` FROM admin_audit_log WHERE target='news'",
|
||||
);
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(audit[0].user_id).toBe(fixture.userId);
|
||||
expect(JSON.parse(audit[0].before).status).toBe("draft");
|
||||
expect(JSON.parse(audit[0].after).status).toBe("published");
|
||||
const operations = await rows(
|
||||
"SELECT kind, actor_id, result_json FROM cms_operations ORDER BY kind",
|
||||
);
|
||||
expect(operations.map((operation) => operation.kind)).toEqual([
|
||||
"news.create",
|
||||
"news.update",
|
||||
]);
|
||||
for (const operation of operations) {
|
||||
expect(operation.actor_id).toBe(fixture.userId);
|
||||
expect(JSON.parse(operation.result_json)).toMatchObject({ ok: true });
|
||||
}
|
||||
const effects = await rows("SELECT topic FROM cms_outbox");
|
||||
expect(effects).toEqual([
|
||||
{ topic: "news.refresh" },
|
||||
{ topic: "news.refresh" },
|
||||
]);
|
||||
expect(await redis.get("cms:news:revision")).not.toBe(draftRevision);
|
||||
});
|
||||
await test.step("anonymous pages and API read the newly published content", async () => {
|
||||
const response = await reader.reload();
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(
|
||||
reader.getByRole("heading", { name: title, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(reader.locator(".article-body")).toContainText(body);
|
||||
await expect(reader.locator(".error-screen-code")).toHaveCount(0);
|
||||
const listing = await anonymous.request
|
||||
.get("/api/articles")
|
||||
.then((response) => response.json());
|
||||
expect(listing.data).toHaveLength(1);
|
||||
expect(listing.data[0]).toMatchObject({
|
||||
id: articleId,
|
||||
title,
|
||||
slug,
|
||||
shortStory: summary,
|
||||
});
|
||||
expect(listing.meta.total).toBe(1);
|
||||
const revision = await redis.get("cms:news:revision");
|
||||
const cached = await redis.get(
|
||||
`news:${revision}:article:v2:slug:${slug}`,
|
||||
);
|
||||
expect(JSON.parse(cached ?? "null")).toMatchObject({
|
||||
id: articleId,
|
||||
title,
|
||||
slug,
|
||||
});
|
||||
expect(
|
||||
(await anonymous.cookies()).some((cookie) =>
|
||||
cookie.name.includes("session-token"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
} finally {
|
||||
if (errors.length)
|
||||
await testInfo.attach("browser-errors", {
|
||||
body: JSON.stringify(errors, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await anonymous.close();
|
||||
await database.end();
|
||||
redis.disconnect();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
if (!process.env.NEWS_E2E_FIXTURE || !process.env.NEWS_E2E_BASE_URL)
|
||||
throw Error(
|
||||
"Run this suite with pnpm test:news:real; it requires disposable services.",
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "news.spec.ts",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 15_000 },
|
||||
outputDir: "../../test-results/news-real",
|
||||
reporter: [
|
||||
["list"],
|
||||
[
|
||||
"html",
|
||||
{ outputFolder: "../../playwright-report/news-real", open: "never" },
|
||||
],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.NEWS_E2E_BASE_URL,
|
||||
locale: "en-US",
|
||||
ignoreHTTPSErrors: true,
|
||||
serviceWorkers: "block",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "off",
|
||||
launchOptions: {
|
||||
executablePath: process.env.UI_TEST_BROWSER_PATH || undefined,
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium-real-news", use: { ...devices["Desktop Chrome"] } },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { createServer, type Server } from "node:https";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import mysql, { type Connection } from "mysql2/promise";
|
||||
import {
|
||||
GenericContainer,
|
||||
Network,
|
||||
type StartedNetwork,
|
||||
type StartedTestContainer,
|
||||
Wait,
|
||||
} from "testcontainers";
|
||||
import { STAFF_ID, STAFF_USERNAME, seedDatabase } from "./schema";
|
||||
|
||||
const root = fileURLToPath(new URL("../../", import.meta.url));
|
||||
const execute = promisify(execFile);
|
||||
const image = process.env.NEWS_E2E_IMAGE;
|
||||
const release = process.env.NEWS_E2E_RELEASE;
|
||||
if (!image)
|
||||
throw Error(
|
||||
"NEWS_E2E_IMAGE is required: build the candidate image before running this test. Docker is mandatory.",
|
||||
);
|
||||
if (!/^epicnext-cms:[a-zA-Z0-9_.-]+$/.test(image))
|
||||
throw Error("NEWS_E2E_IMAGE must name a local epicnext-cms candidate tag.");
|
||||
if (release && !/^[0-9a-f]{40}$/.test(release))
|
||||
throw Error("NEWS_E2E_RELEASE must be a full commit SHA.");
|
||||
|
||||
// Never propagate checkout .env, NODE_OPTIONS, installation DB URLs or service tokens.
|
||||
const inherited = (names: string[]): NodeJS.ProcessEnv => ({
|
||||
NODE_ENV: "test",
|
||||
...Object.fromEntries(
|
||||
names.flatMap((name) =>
|
||||
process.env[name] === undefined ? [] : [[name, process.env[name]]],
|
||||
),
|
||||
),
|
||||
});
|
||||
const hostEnv = inherited([
|
||||
"PATH",
|
||||
"Path",
|
||||
"SystemRoot",
|
||||
"ComSpec",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"TMPDIR",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"LOCALAPPDATA",
|
||||
]);
|
||||
const dockerEnv = {
|
||||
...hostEnv,
|
||||
...inherited([
|
||||
"DOCKER_HOST",
|
||||
"DOCKER_CONTEXT",
|
||||
"DOCKER_CONFIG",
|
||||
"DOCKER_CERT_PATH",
|
||||
"DOCKER_TLS_VERIFY",
|
||||
]),
|
||||
};
|
||||
const secrets = Array.from({ length: 4 }, () =>
|
||||
randomBytes(32).toString("hex"),
|
||||
);
|
||||
const [databasePassword, redisPassword, staffPassword, authSecret] = secrets;
|
||||
const redact = (text: string) =>
|
||||
secrets.reduce(
|
||||
(value, secret) => value.replaceAll(secret, "[fixture secret]"),
|
||||
text,
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const onSignal = () => abort.abort();
|
||||
process.once("SIGINT", onSignal);
|
||||
process.once("SIGTERM", onSignal);
|
||||
let network: StartedNetwork | undefined;
|
||||
let maria: StartedTestContainer | undefined;
|
||||
let redis: StartedTestContainer | undefined;
|
||||
let app: StartedTestContainer | undefined;
|
||||
let database: Connection | undefined;
|
||||
let proxy: Server | undefined;
|
||||
let temporary: string | undefined;
|
||||
let logs = "";
|
||||
|
||||
try {
|
||||
// Resolve the existing image before Testcontainers; starting by immutable ID cannot pull a tag.
|
||||
const inspected = await execute(
|
||||
"docker",
|
||||
["image", "inspect", image, "--format", "{{json .}}"],
|
||||
{ env: dockerEnv, timeout: 15_000 },
|
||||
);
|
||||
const candidate = JSON.parse(inspected.stdout) as {
|
||||
Id: string;
|
||||
Config: { Labels?: Record<string, string> };
|
||||
};
|
||||
if (!/^sha256:[0-9a-f]{64}$/.test(candidate.Id))
|
||||
throw Error("Candidate image identity is invalid");
|
||||
if (
|
||||
release &&
|
||||
candidate.Config.Labels?.["org.opencontainers.image.revision"] !== release
|
||||
)
|
||||
throw Error("Candidate image revision does not match NEWS_E2E_RELEASE");
|
||||
abort.signal.throwIfAborted();
|
||||
temporary = await mkdtemp(join(tmpdir(), "epicnext-news-e2e-"));
|
||||
// Fresh local-only TLS material never enters the repository or build artifacts.
|
||||
await execute(
|
||||
"openssl",
|
||||
[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
join(temporary, "localhost.key"),
|
||||
"-out",
|
||||
join(temporary, "localhost.crt"),
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
"-addext",
|
||||
"subjectAltName=IP:127.0.0.1,DNS:localhost",
|
||||
],
|
||||
{ cwd: temporary, env: hostEnv, timeout: 30_000, signal: abort.signal },
|
||||
);
|
||||
console.log("News browser gate: starting isolated MariaDB and Redis");
|
||||
network = await new Network().start();
|
||||
const services = await Promise.allSettled([
|
||||
new GenericContainer("mariadb:11.4.5")
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases("news-db")
|
||||
.withEnvironment({
|
||||
MARIADB_ROOT_PASSWORD: randomBytes(32).toString("hex"),
|
||||
MARIADB_DATABASE: "news_e2e",
|
||||
MARIADB_USER: "news_e2e",
|
||||
MARIADB_PASSWORD: databasePassword,
|
||||
})
|
||||
.withExposedPorts(3306)
|
||||
.withHealthCheck({
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"],
|
||||
interval: 1000,
|
||||
timeout: 5000,
|
||||
retries: 60,
|
||||
startPeriod: 1000,
|
||||
})
|
||||
.withWaitStrategy(Wait.forHealthCheck())
|
||||
.withStartupTimeout(120_000)
|
||||
.start()
|
||||
.then((container) => {
|
||||
maria = container;
|
||||
}),
|
||||
new GenericContainer("redis:7.4.2-alpine")
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases("news-redis")
|
||||
.withCommand(["redis-server", "--requirepass", redisPassword])
|
||||
.withExposedPorts(6379)
|
||||
.withWaitStrategy(Wait.forLogMessage("Ready to accept connections"))
|
||||
.withStartupTimeout(60_000)
|
||||
.start()
|
||||
.then((container) => {
|
||||
redis = container;
|
||||
}),
|
||||
]);
|
||||
for (const service of services)
|
||||
if (service.status === "rejected") throw service.reason;
|
||||
if (!maria || !redis) throw Error("Disposable services did not start");
|
||||
abort.signal.throwIfAborted();
|
||||
database = await mysql.createConnection({
|
||||
host: maria.getHost(),
|
||||
port: maria.getMappedPort(3306),
|
||||
user: "news_e2e",
|
||||
password: databasePassword,
|
||||
database: "news_e2e",
|
||||
charset: "utf8mb4",
|
||||
timezone: "Z",
|
||||
supportBigNumbers: true,
|
||||
bigNumberStrings: true,
|
||||
});
|
||||
await seedDatabase(database, staffPassword);
|
||||
await database.end();
|
||||
database = undefined;
|
||||
|
||||
let upstream: URL | undefined;
|
||||
let origin = "";
|
||||
proxy = createServer(
|
||||
{
|
||||
cert: await readFile(join(temporary, "localhost.crt")),
|
||||
key: await readFile(join(temporary, "localhost.key")),
|
||||
},
|
||||
(request, response) => {
|
||||
if (!upstream || request.headers.host !== new URL(origin).host) {
|
||||
response.writeHead(503);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
// A real local TLS edge, with the same forwarded-host/proto contract as deployment.
|
||||
const headers = { ...request.headers };
|
||||
delete headers["cf-connecting-ip"];
|
||||
delete headers["x-real-client-ip"];
|
||||
headers["x-forwarded-for"] = "127.0.0.1";
|
||||
headers["x-real-ip"] = "127.0.0.1";
|
||||
headers["x-forwarded-host"] = new URL(origin).host;
|
||||
headers["x-forwarded-proto"] = "https";
|
||||
const forwarded = httpRequest(
|
||||
{
|
||||
hostname: upstream.hostname,
|
||||
port: upstream.port,
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
headers,
|
||||
},
|
||||
(received) => {
|
||||
response.writeHead(received.statusCode ?? 502, received.headers);
|
||||
received.pipe(response);
|
||||
},
|
||||
);
|
||||
forwarded.on("error", () => {
|
||||
if (!response.headersSent) response.writeHead(502);
|
||||
response.end();
|
||||
});
|
||||
request.on("aborted", () => forwarded.destroy());
|
||||
request.pipe(forwarded);
|
||||
},
|
||||
);
|
||||
proxy.listen(0, "127.0.0.1");
|
||||
await once(proxy, "listening");
|
||||
const address = proxy.address();
|
||||
if (!address || typeof address === "string")
|
||||
throw Error("Local TLS listener is unavailable");
|
||||
origin = `https://127.0.0.1:${address.port}`;
|
||||
console.log("News browser gate: starting the production candidate image");
|
||||
app = await new GenericContainer(candidate.Id.slice("sha256:".length))
|
||||
.withNetwork(network)
|
||||
.withEnvironment({
|
||||
NODE_ENV: "production",
|
||||
HOSTNAME: "0.0.0.0",
|
||||
PORT: "3002",
|
||||
DATABASE_URL: `mysql://news_e2e:${databasePassword}@news-db:3306/news_e2e`,
|
||||
REDIS_URL: `redis://:${redisPassword}@news-redis:6379/0`,
|
||||
HOTEL_NAME: "News browser fixture",
|
||||
AUTH_SECRET: authSecret,
|
||||
APP_URL: origin,
|
||||
NEXT_PUBLIC_APP_URL: origin,
|
||||
AUTH_URL: origin,
|
||||
RCON_HOST: "127.0.0.1",
|
||||
RCON_PORT: "9",
|
||||
RCON_TIMEOUT_MS: "100",
|
||||
RCON_MAX_RETRIES: "1",
|
||||
IMAGING_UPSTREAM_URL: "http://127.0.0.1:9",
|
||||
LOG_LEVEL: "warn",
|
||||
})
|
||||
.withExposedPorts(3002)
|
||||
.withWaitStrategy(Wait.forHttp("/api/health", 3002).forStatusCode(200))
|
||||
.withStartupTimeout(120_000)
|
||||
.withLogConsumer((stream) =>
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
logs = (logs + chunk.toString()).slice(-2_000_000);
|
||||
}),
|
||||
)
|
||||
.start();
|
||||
upstream = new URL(`http://${app.getHost()}:${app.getMappedPort(3002)}`);
|
||||
abort.signal.throwIfAborted();
|
||||
const health = (await fetch(new URL("/api/health", upstream), {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}).then((response) => response.json())) as {
|
||||
status?: string;
|
||||
database?: boolean;
|
||||
redis?: boolean;
|
||||
};
|
||||
if (
|
||||
health.status !== "ok" ||
|
||||
health.database !== true ||
|
||||
health.redis !== true
|
||||
)
|
||||
throw Error("Candidate health does not confirm both disposable services");
|
||||
const fixturePath = join(temporary, "fixture.json");
|
||||
await writeFile(
|
||||
fixturePath,
|
||||
JSON.stringify({
|
||||
username: STAFF_USERNAME,
|
||||
userId: STAFF_ID,
|
||||
password: staffPassword,
|
||||
database: {
|
||||
host: maria.getHost(),
|
||||
port: maria.getMappedPort(3306),
|
||||
user: "news_e2e",
|
||||
password: databasePassword,
|
||||
database: "news_e2e",
|
||||
},
|
||||
redis: {
|
||||
host: redis.getHost(),
|
||||
port: redis.getMappedPort(6379),
|
||||
password: redisPassword,
|
||||
},
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
console.log(
|
||||
"News browser gate: login, draft, preview, publish and anonymous read",
|
||||
);
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
resolve(root, "node_modules/@playwright/test/cli.js"),
|
||||
"test",
|
||||
"--config",
|
||||
"e2e/news-real/playwright.config.ts",
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: {
|
||||
...hostEnv,
|
||||
...inherited([
|
||||
"DISPLAY",
|
||||
"XAUTHORITY",
|
||||
"PLAYWRIGHT_BROWSERS_PATH",
|
||||
"UI_TEST_BROWSER_PATH",
|
||||
"CI",
|
||||
]),
|
||||
NEWS_E2E_FIXTURE: fixturePath,
|
||||
NEWS_E2E_BASE_URL: origin,
|
||||
},
|
||||
stdio: "inherit",
|
||||
signal: abort.signal,
|
||||
},
|
||||
);
|
||||
const [code] = (await once(child, "exit")) as [number | null];
|
||||
if (code !== 0)
|
||||
throw Error(`Real news browser suite failed (exit ${code ?? "signal"})`);
|
||||
console.log("News browser gate passed");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
redact(
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
// Stop only objects created by this run. Testcontainers' resource reaper also owns them.
|
||||
const cleanups: Array<[string, () => Promise<unknown>]> = [
|
||||
[
|
||||
"TLS proxy",
|
||||
async () => {
|
||||
proxy?.closeAllConnections();
|
||||
if (proxy)
|
||||
await new Promise<void>((done) => proxy?.close(() => done()));
|
||||
},
|
||||
],
|
||||
["candidate", async () => app?.stop({ timeout: 10_000 })],
|
||||
["database connection", async () => database?.end()],
|
||||
["Redis", async () => redis?.stop()],
|
||||
["MariaDB", async () => maria?.stop()],
|
||||
["network", async () => network?.stop()],
|
||||
[
|
||||
"temporary credentials",
|
||||
async () => {
|
||||
if (!temporary) return;
|
||||
if (
|
||||
dirname(resolve(temporary)) !== resolve(tmpdir()) ||
|
||||
!basename(temporary).startsWith("epicnext-news-e2e-")
|
||||
)
|
||||
throw Error(
|
||||
"Temporary credentials path escaped the fixture directory",
|
||||
);
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
},
|
||||
],
|
||||
];
|
||||
for (const [name, cleanup] of cleanups) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
console.error(`Cleanup failed for ${name}: ${redact(String(error))}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
await mkdir(resolve(root, "test-results/news-real"), { recursive: true });
|
||||
await writeFile(
|
||||
resolve(root, "test-results/news-real/server.log"),
|
||||
redact(logs),
|
||||
);
|
||||
process.removeListener("SIGINT", onSignal);
|
||||
process.removeListener("SIGTERM", onSignal);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { is, SQL } from "drizzle-orm";
|
||||
import {
|
||||
getTableConfig,
|
||||
MySqlDialect,
|
||||
type MySqlTable,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
import { bcrypt } from "hash-wasm";
|
||||
import mysql, { type Connection } from "mysql2/promise";
|
||||
import { splitSqlStatements } from "../../scripts/sql-statements";
|
||||
import * as schema from "../../src/db/schema";
|
||||
|
||||
export const STAFF_ID = 7;
|
||||
export const STAFF_USERNAME = "NewsEditor";
|
||||
|
||||
// Use the actual ORM definitions for the emulator tables this browser journey reads.
|
||||
// CMS recovery/outbox tables below come from the shipped migrations themselves.
|
||||
const tables: MySqlTable[] = [
|
||||
schema.User,
|
||||
schema.Ban,
|
||||
schema.WebsiteSetting,
|
||||
schema.WebsiteLanguages,
|
||||
schema.AclRole,
|
||||
schema.AclPermission,
|
||||
schema.AclModelRole,
|
||||
schema.AclModelPermission,
|
||||
schema.WebsiteArticles,
|
||||
schema.WebsiteArticleComments,
|
||||
schema.WebsiteArticleReactions,
|
||||
schema.WebsiteLoginLogs,
|
||||
schema.AdminAuditLog,
|
||||
schema.StaffActivities,
|
||||
schema.WebsiteIpBlacklist,
|
||||
schema.WebsiteIpWhitelist,
|
||||
schema.AlertLogs,
|
||||
schema.ThemeScope,
|
||||
schema.ThemeScopeValue,
|
||||
schema.UsersCurrency,
|
||||
schema.MessengerFriendrequests,
|
||||
schema.MessengerFriendships,
|
||||
schema.MessengerOffline,
|
||||
schema.Rooms,
|
||||
schema.UsersBadges,
|
||||
schema.UsersSettings,
|
||||
schema.UserReferrals,
|
||||
schema.WebsiteEvent,
|
||||
schema.WebsiteEventType,
|
||||
];
|
||||
const identifier = (name: string) => `\`${name.replaceAll("`", "``")}\``;
|
||||
|
||||
export function fixtureStatements(): string[] {
|
||||
const dialect = new MySqlDialect();
|
||||
return tables.map((table) => {
|
||||
const config = getTableConfig(table);
|
||||
// Fail on new constraints instead of silently reducing the fixture's integrity.
|
||||
if (
|
||||
config.foreignKeys.length ||
|
||||
config.checks.length ||
|
||||
config.uniqueConstraints.length
|
||||
)
|
||||
throw Error(`Fixture requires explicit constraints for ${config.name}`);
|
||||
const columns = config.columns.map((column) => {
|
||||
let ddl = `${identifier(column.name)} ${column.getSQLType()}`;
|
||||
ddl += column.notNull ? " NOT NULL" : " NULL";
|
||||
if ("autoIncrement" in column && column.autoIncrement)
|
||||
ddl += " AUTO_INCREMENT";
|
||||
if (column.primary) ddl += " PRIMARY KEY";
|
||||
if (column.isUnique) ddl += " UNIQUE";
|
||||
if (column.default !== undefined) {
|
||||
if (is(column.default, SQL)) {
|
||||
const query = dialect.sqlToQuery(column.default);
|
||||
if (query.params.length)
|
||||
throw Error(
|
||||
`Fixture requires parameterized default for ${config.name}.${column.name}`,
|
||||
);
|
||||
ddl += ` DEFAULT ${query.sql}`;
|
||||
} else if (
|
||||
typeof column.default === "string" ||
|
||||
typeof column.default === "number" ||
|
||||
typeof column.default === "boolean" ||
|
||||
column.default === null
|
||||
)
|
||||
ddl += ` DEFAULT ${mysql.escape(column.default)}`;
|
||||
else
|
||||
throw Error(
|
||||
`Fixture requires explicit default for ${config.name}.${column.name}`,
|
||||
);
|
||||
} else if (!column.notNull) ddl += " DEFAULT NULL";
|
||||
return ddl;
|
||||
});
|
||||
for (const key of config.primaryKeys)
|
||||
columns.push(
|
||||
`PRIMARY KEY (${key.columns.map((column) => identifier(column.name)).join(", ")})`,
|
||||
);
|
||||
for (const index of config.indexes) {
|
||||
if (!index.config.unique) continue;
|
||||
const names = index.config.columns.map((column) => {
|
||||
if (is(column, SQL))
|
||||
throw Error(`Fixture requires expression index for ${config.name}`);
|
||||
return identifier(column.name);
|
||||
});
|
||||
columns.push(
|
||||
`UNIQUE KEY ${identifier(index.config.name)} (${names.join(", ")})`,
|
||||
);
|
||||
}
|
||||
return `CREATE TABLE ${identifier(config.name)} (${columns.join(",\n")}) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`;
|
||||
});
|
||||
}
|
||||
|
||||
export async function seedDatabase(
|
||||
connection: Connection,
|
||||
staffPassword: string,
|
||||
) {
|
||||
for (const statement of fixtureStatements())
|
||||
await connection.query(statement);
|
||||
// Rank metadata belongs to the emulator and is queried as raw SQL by authorization.
|
||||
await connection.query(
|
||||
"CREATE TABLE permission_ranks (id INT PRIMARY KEY, rank_name VARCHAR(255) NOT NULL) ENGINE=InnoDB",
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO permission_ranks (id, rank_name) VALUES (1, 'Member'), (7, 'Editor'), (9, 'Owner')",
|
||||
);
|
||||
for (const migration of [
|
||||
"0026_article_editor_recovery.sql",
|
||||
"0031_operations_outbox.sql",
|
||||
]) {
|
||||
const contents = await readFile(
|
||||
new URL(`../../drizzle/migrations/${migration}`, import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
for (const statement of splitSqlStatements(contents))
|
||||
await connection.query(statement);
|
||||
}
|
||||
const password = await bcrypt({
|
||||
password: staffPassword,
|
||||
salt: randomBytes(16),
|
||||
costFactor: 12,
|
||||
outputType: "encoded",
|
||||
});
|
||||
const ownerPassword = await bcrypt({
|
||||
password: randomBytes(32).toString("hex"),
|
||||
salt: randomBytes(16),
|
||||
costFactor: 12,
|
||||
outputType: "encoded",
|
||||
});
|
||||
await connection.query(
|
||||
"INSERT INTO users (id, username, password, rank, account_created, ip_register, ip_current, mail, mail_verified) VALUES (?, ?, ?, 7, ?, '127.0.0.1', '127.0.0.1', '[email protected]', '1'), (9, 'FixtureOwner', ?, 9, ?, '127.0.0.1', '127.0.0.1', '[email protected]', '1')",
|
||||
[
|
||||
STAFF_ID,
|
||||
STAFF_USERNAME,
|
||||
password,
|
||||
Math.floor(Date.now() / 1000),
|
||||
ownerPassword,
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO acl_roles (id, slug, title) VALUES (1, 'news_editor', 'News editor')",
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO acl_model_roles (model_type, model_id, role_id) VALUES ('User', ?, 1)",
|
||||
[STAFF_ID],
|
||||
);
|
||||
for (const [index, slug] of [
|
||||
"admin.dashboard",
|
||||
"admin.news.view",
|
||||
"admin.news.edit",
|
||||
].entries()) {
|
||||
await connection.query(
|
||||
"INSERT INTO acl_permissions (id, slug, title) VALUES (?, ?, ?)",
|
||||
[index + 1, slug, slug],
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO acl_model_permissions (model_type, model_id, permission_id) VALUES ('Role', 1, ?)",
|
||||
[index + 1],
|
||||
);
|
||||
}
|
||||
for (const [key, value] of Object.entries({
|
||||
hotel_name: "News browser fixture",
|
||||
captcha_provider: "none",
|
||||
require_email_verification: "1",
|
||||
force_staff_2fa: "0",
|
||||
maintenance_enabled: "0",
|
||||
abuse_guard_enabled: "0",
|
||||
radio_enabled: "0",
|
||||
}))
|
||||
await connection.query(
|
||||
"INSERT INTO website_settings (`key`, value) VALUES (?, ?)",
|
||||
[key, value],
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO website_languages (country_code, language) VALUES ('en', 'English')",
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@
|
||||
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
|
||||
"i18n:audit": "node scripts/audit-cms-translations.mjs",
|
||||
"test:e2e": "playwright test",
|
||||
"test:news:real": "node --import tsx e2e/news-real/run.ts",
|
||||
"test:ui": "playwright test --config playwright.ui.config.ts",
|
||||
"test:ui:update": "playwright test --config playwright.ui.config.ts --update-snapshots",
|
||||
"performance:report": "node scripts/performance-report.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: "**/ui/**",
|
||||
testIgnore: ["**/ui/**", "**/news-real/**"],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 1,
|
||||
|
||||
@@ -115,6 +115,9 @@ if report_container="$(docker create --entrypoint /bin/true "$image")" && [ -n "
|
||||
else
|
||||
echo "Warning: could not extract build performance report" >&2
|
||||
fi
|
||||
# Exercise the candidate with disposable services before any live migration or cutover.
|
||||
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
||||
check_current
|
||||
pnpm db:migrate
|
||||
check_current
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@ describe("deployment transaction", () => {
|
||||
const result = simulate("success");
|
||||
expect(result.status, result.output).toBe(0);
|
||||
expect(result.app).toBe("new");
|
||||
expect(result.calls).toContain("node --import tsx e2e/news-real/run.ts");
|
||||
expect(result.calls.indexOf("e2e/news-real/run.ts")).toBeLessThan(
|
||||
result.calls.indexOf("pnpm db:migrate"),
|
||||
);
|
||||
expect(result.backup).toBe(false);
|
||||
expect(result.calls.indexOf("pnpm db:migrate")).toBeLessThan(
|
||||
result.calls.indexOf("docker stop"),
|
||||
@@ -131,6 +135,7 @@ describe("deployment transaction", () => {
|
||||
"remote-failure",
|
||||
"build-failure",
|
||||
"migration-failure",
|
||||
"news-e2e-failure",
|
||||
])("leaves the active container untouched after %s", (scenario) => {
|
||||
const result = simulate(scenario);
|
||||
expect(result.status).not.toBe(0);
|
||||
|
||||
@@ -52,7 +52,8 @@ export -f git flock pnpm curl sleep docker
|
||||
node() {
|
||||
echo "node $*" >> "$TEST_DIR/calls"
|
||||
case "$SCENARIO" in
|
||||
release-failure) return 1 ;;
|
||||
release-failure) [[ "$*" != *verify-deployed-release.mjs* ]] || return 1 ;;
|
||||
news-e2e-failure) [[ "$*" != *e2e/news-real/run.ts* ]] || return 1 ;;
|
||||
|
||||
esac
|
||||
return 0
|
||||
|
||||
Reference in new issue
Block a user