Skip to content
Draft
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ the foreground. `TASKNOTES_ANDROID_SKIP_FCM_DELIVERY=1` may be used to
continue diagnosing unrelated smoke steps during a confirmed FCM outage, but
that run does not count as a push-delivery pass.

Run the gate against exactly one connected Android device or emulator after a
Firebase-configured test build:

```sh
TASKNOTES_FIREBASE_PROJECT_ID=tasknotes-462906 \
VITE_TASKNOTES_ANDROID_NOTIFICATION_TEST=1 pnpm cap:sync
(cd android && ./gradlew test lint assembleDebug)
pnpm test:android-smoke
```

The caller must have `adb`, `gcloud`, and permission to send Cloud Messaging
messages to the TaskNotes Firebase project. The build-only smoke entry is
removed from ordinary production bundles.

## Allow Connect to send

Connect sends through the FCM HTTP v1 API. Grant the Connect sender service
Expand Down
110 changes: 110 additions & 0 deletions e2e/dogfood/tasknotes-response-recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import { readdir, readFile } from "node:fs/promises";

const collectionDir = requiredEnvironment(
"MDBASE_CONNECT_DOGFOOD_COLLECTION_DIR",
);
const userName =
process.env.MDBASE_CONNECT_DOGFOOD_USER_NAME ?? "TaskNotes Dogfood";
const userEmail =
process.env.MDBASE_CONNECT_DOGFOOD_USER_EMAIL ??
"tasknotes-dogfood@localhost.test";

function requiredEnvironment(name: string): string {
const value = process.env[name];
if (!value)
throw new Error(`${name} is required for the isolated dogfood test.`);
return value;
}

async function markdownFiles(): Promise<string[]> {
return (await readdir(collectionDir, { recursive: true }))
.filter((file) => file.endsWith(".md"))
.sort();
}

async function authorize(page: Page): Promise<void> {
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Open TaskNotes with mdbase." }),
).toBeVisible();
await page.getByRole("button", { name: "Continue to mdbase" }).click();
await expect(
page.getByRole("heading", { name: "Open your account" }),
).toBeVisible();
await page.getByLabel("Name").fill(userName);
await page.getByLabel("Email").fill(userEmail);
await page.getByRole("button", { name: "Continue" }).click();

await expect(page.getByRole("heading", { name: "TaskNotes" })).toBeVisible();
const collection = page.locator('input[type="radio"]').first();
await expect(collection).toBeAttached();
if (!(await collection.isChecked())) await collection.check();
await page.getByRole("button", { name: /allow TaskNotes$/i }).click();

const capture = page.getByLabel("New task title");
const applyDefinitions = page.getByRole("button", {
name: "Review and update definitions",
});
await expect(capture.or(applyDefinitions)).toBeVisible();
if (await applyDefinitions.isVisible()) await applyDefinitions.click();
await expect(capture).toBeVisible();
}

function isCreateOperation(route: Route): boolean {
const request = route.request();
if (request.method() !== "POST") return false;
let body: Record<string, unknown>;
try {
body = request.postDataJSON() as Record<string, unknown>;
} catch {
return false;
}
return (
body.operation === "create" || request.url().endsWith("/operations/create")
);
}

test("recovers one exact task after authority responses are lost", async ({
page,
}) => {
await authorize(page);
const filesBefore = await markdownFiles();
const title = `TaskNotes durable recovery ${Date.now()}`;

let injectLoss = true;
let droppedResponses = 0;
await page.route("**/*", async (route) => {
if (!injectLoss || !isCreateOperation(route)) {
await route.continue();
return;
}
await route.fetch();
droppedResponses += 1;
await route.abort("failed");
});

const input = page.getByLabel("New task title");
await input.fill(title);
await page.getByRole("button", { name: "Add", exact: true }).click();

await expect(page.getByRole("alert")).toContainText(
"The task could not finish. Your draft is still here.",
);
await expect(input).toHaveValue(title);
expect(droppedResponses).toBeGreaterThan(0);

injectLoss = false;
await page.unrouteAll({ behavior: "wait" });
await page.reload();
await expect(page.getByText(title, { exact: true })).toBeVisible();

const filesAfter = await markdownFiles();
const created = filesAfter.filter((file) => !filesBefore.includes(file));
const matching: string[] = [];
for (const file of created) {
const markdown = await readFile(`${collectionDir}/${file}`, "utf8");
if (markdown.includes(title)) matching.push(file);
}
expect(matching).toHaveLength(1);
});
14 changes: 8 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"test:coverage:layers": "pnpm test:coverage:application && pnpm test:coverage:domain",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:connect": "playwright test --config playwright.dogfood.config.ts",
"test:android-smoke": "node scripts/android-smoke.mjs",
"test:production-smoke": "node scripts/production-smoke.mjs",
"test:conformance:tasknotes": "node scripts/run-tasknotes-conformance.mjs",
"test:conformance:mdbase": "node scripts/verify-mdbase-collection.mjs",
Expand All @@ -48,8 +50,8 @@
"@fullcalendar/list": "6.1.17",
"@fullcalendar/react": "6.1.17",
"@fullcalendar/timegrid": "6.1.17",
"@mdbase-dev/connect": "file:vendor/mdbase-dev-connect-0.1.0-beta.29-4371c96eaa70.tgz",
"@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.29-4371c96eaa70.tgz",
"@mdbase-dev/connect": "file:vendor/mdbase-dev-connect-0.1.0-beta.32-d5560b792b8f.tgz",
"@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.32-d5560b792b8f.tgz",
"@tasknotes/model": "file:vendor/tasknotes-model-0.3.0-rc.9.tgz",
"dexie": "4.4.4",
"firebase": "12.16.0",
Expand All @@ -66,8 +68,8 @@
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@callumalpass/mdbase": "file:vendor/callumalpass-mdbase-0.3.0-rc.4-4f28ea27a15a.tgz",
"@mdbase-dev/connect-dev": "file:vendor/mdbase-dev-connect-dev-0.1.0-beta.29-4371c96eaa70.tgz",
"@mdbase-dev/connect-testing": "file:vendor/mdbase-dev-connect-testing-0.1.0-beta.29-4371c96eaa70.tgz",
"@mdbase-dev/connect-dev": "file:vendor/mdbase-dev-connect-dev-0.1.0-beta.32-d5560b792b8f.tgz",
"@mdbase-dev/connect-testing": "file:vendor/mdbase-dev-connect-testing-0.1.0-beta.32-d5560b792b8f.tgz",
"@capacitor/android": "8.4.2",
"@capacitor/assets": "3.0.5",
"@capacitor/cli": "8.4.2",
Expand Down Expand Up @@ -96,8 +98,8 @@
},
"pnpm": {
"overrides": {
"@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.29-4371c96eaa70.tgz",
"@mdbase-dev/connect": "file:vendor/mdbase-dev-connect-0.1.0-beta.29-4371c96eaa70.tgz"
"@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.32-d5560b792b8f.tgz",
"@mdbase-dev/connect": "file:vendor/mdbase-dev-connect-0.1.0-beta.32-d5560b792b8f.tgz"
}
}
}
1 change: 1 addition & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const webServerCommand =

export default defineConfig({
testDir: "./e2e",
testIgnore: "**/dogfood/**",
fullyParallel: false,
// Desktop and mobile exercise the same direct mdbase application path.
workers: 1,
Expand Down
20 changes: 20 additions & 0 deletions playwright.dogfood.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
testDir: "./e2e/dogfood",
timeout: 120_000,
expect: { timeout: 30_000 },
workers: 1,
retries: 0,
use: {
baseURL:
process.env.MDBASE_CONNECT_DOGFOOD_APP_URL ?? "https://localhost:5199",
trace: "retain-on-failure",
screenshot: "only-on-failure",
ignoreHTTPSErrors: true,
launchOptions: {
args: ["--host-resolver-rules=MAP host.docker.internal 127.0.0.1"],
},
...devices["Desktop Chrome"],
},
});
Loading