mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge origin/main (v1.44.2) into fix/v145-sheet-motion
next stays on main's 16.4.2 (the RCE and Server Actions advisories) with its two companions; release's newer dev tools and its own additions are kept. The lockfile is release's, re-resolved. pt-BR, which main does not have, takes the one key main added.
This commit is contained in:
commit
9c8972bc96
86 files changed
+5051
-487
No files matched your search
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Structural guard (#1267): a browser request that waits on a model call
|
||||
* takes its abort from the person's response-timeout setting, never from a
|
||||
* constant.
|
||||
*
|
||||
* The force regenerate aborted at a fixed 45 s while the server still had
|
||||
* minutes left on the person's 600 s setting, and a save of the about-me
|
||||
* self-context (which derives the clarifying questions inline) ran under the
|
||||
* 15 s request default. Both now read `clientAbortMs` through the account
|
||||
* payload's `ai.provider.responseTimeoutMs`; the inventory of every such
|
||||
* request is in the release notes for #1267.
|
||||
*
|
||||
* Its limit: it reads source text. A new synchronous AI route called from the
|
||||
* browser with a fixed `AbortSignal.timeout(...)` is not found by name here;
|
||||
* `use-insights-advisor-force.test.ts` proves the behaviour for the
|
||||
* regenerate with fake timers.
|
||||
*
|
||||
* Mutation check: put `FORCE_ADVISOR_TIMEOUT_MS = 45_000` back into the
|
||||
* advisor, or drop the `signal` from one about-me save, and this goes red.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
/** The text of the call that starts at `open` (the index of its `(`). */
|
||||
function callText(source: string, open: number): string {
|
||||
let depth = 0;
|
||||
for (let i = open; i < source.length; i += 1) {
|
||||
if (source[i] === "(") depth += 1;
|
||||
else if (source[i] === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(open, i + 1);
|
||||
}
|
||||
}
|
||||
return source.slice(open);
|
||||
}
|
||||
|
||||
describe("browser aborts for model-backed requests", () => {
|
||||
it("the force regenerate derives its abort from the setting", () => {
|
||||
const source = stripComments(
|
||||
readFileSync(
|
||||
join(SRC, "components/insights/use-insights-advisor.ts"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
expect(source).not.toMatch(/FORCE_ADVISOR_TIMEOUT_MS/);
|
||||
expect(source).toMatch(
|
||||
/options\.force\s*\?\s*forceAdvisorTimeoutMs\(options\.responseTimeoutMs\)/,
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/fetchAdvisor\(\{\s*force:\s*true,\s*responseTimeoutMs\s*\}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("every about-me save carries the setting-derived signal", () => {
|
||||
const sites: string[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const rel of walkSourceFiles(SRC, { floor: 3000 })) {
|
||||
if (rel.includes("__tests__/") || rel.startsWith("generated/")) continue;
|
||||
if (!rel.startsWith("components/") && !rel.startsWith("app/")) continue;
|
||||
const source = stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
const pattern =
|
||||
/apiPut(?:<[^>]*>)?\s*\(\s*["']\/api\/coach\/about-me["']/g;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const open = (match.index ?? 0) + match[0].indexOf("(");
|
||||
sites.push(rel);
|
||||
if (
|
||||
!/signal:\s*AbortSignal\.timeout\(saveTimeoutMs\)/.test(
|
||||
callText(source, open),
|
||||
)
|
||||
) {
|
||||
missing.push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Six saves today; a matcher that finds none must not read as clean.
|
||||
expect(sites.length).toBeGreaterThanOrEqual(6);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The image optimizer stays off while nothing uses it.
|
||||
*
|
||||
* `next.config.ts` sets `images.unoptimized`, which makes Next answer
|
||||
* `/_next/image` with 404 before it reads a parameter, so the route that
|
||||
* fetches, decodes and re-encodes images is not reachable at all. Two ways
|
||||
* to undo that quietly, and this pins both:
|
||||
*
|
||||
* 1. A component imports `next/image` (or `next/legacy/image`) while the
|
||||
* optimizer is off. It would render, unoptimized, and the next person
|
||||
* to notice the missing optimization would switch the optimizer back on
|
||||
* without the review this flag deserves. Using `next/image` means
|
||||
* revisiting this decision, so the import fails here first.
|
||||
* 2. The optimizer comes back on with `dangerouslyAllowSVG`. An SVG is a
|
||||
* document, not a picture, and the framework's own advisories have been
|
||||
* about exactly that input. Re-enabling the optimizer is allowed; doing
|
||||
* it with SVG input allowed is not.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
requireFloor,
|
||||
stripComments,
|
||||
walkSourceFiles,
|
||||
} from "./helpers/source-files";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SRC = join(ROOT, "src");
|
||||
|
||||
type ImagesConfig = {
|
||||
unoptimized?: boolean;
|
||||
dangerouslyAllowSVG?: boolean;
|
||||
};
|
||||
|
||||
async function imagesConfig(): Promise<ImagesConfig> {
|
||||
const mod = (await import("../../next.config")) as {
|
||||
default: { images?: ImagesConfig };
|
||||
};
|
||||
return mod.default.images ?? {};
|
||||
}
|
||||
|
||||
// Any import of the module: static, dynamic, `require`, side effect or
|
||||
// re-export. Two literals with one shape rather than one built from a string,
|
||||
// so nothing has to be escaped into a pattern.
|
||||
const NEXT_IMAGE_IMPORT =
|
||||
/(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)["']next\/(?:legacy\/)?image["']/m;
|
||||
const NEXT_LINK_IMPORT =
|
||||
/(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)["']next\/link["']/m;
|
||||
|
||||
/** Every non-generated, non-test source file under `src/` matching `re`. */
|
||||
function importers(re: RegExp): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((rel) => !rel.startsWith("generated/"))
|
||||
.filter((rel) => !rel.includes("__tests__/"))
|
||||
.filter((rel) =>
|
||||
re.test(stripComments(readFileSync(join(SRC, rel), "utf8"))),
|
||||
);
|
||||
}
|
||||
|
||||
describe("next/image optimizer", () => {
|
||||
it("the import matcher recognises every form of the import", () => {
|
||||
// The sweep below passes when it matches nothing, so the matcher has to
|
||||
// be shown to match before an empty result means anything.
|
||||
for (const line of [
|
||||
'import Image from "next/image";',
|
||||
"import Image from 'next/legacy/image';",
|
||||
'const Image = await import("next/image");',
|
||||
'const Image = require("next/image");',
|
||||
'import "next/image";',
|
||||
'export { default } from "next/image";',
|
||||
]) {
|
||||
expect(NEXT_IMAGE_IMPORT.test(line), line).toBe(true);
|
||||
}
|
||||
for (const line of [
|
||||
"// `next/image` is wrong here",
|
||||
'const path = "/_next/image?url=x";',
|
||||
'import { foo } from "next/image-extra";',
|
||||
]) {
|
||||
expect(NEXT_IMAGE_IMPORT.test(stripComments(line)), line).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("the same sweep finds the imports of a sibling module", () => {
|
||||
// An empty result for `next/image` is only worth something if the walk,
|
||||
// the comment stripping and the matcher together do find real imports.
|
||||
// `next/link` is imported the same way all over the tree.
|
||||
requireFloor("next/link importers", importers(NEXT_LINK_IMPORT), 100);
|
||||
});
|
||||
|
||||
it("is off, and then nothing imports next/image", async () => {
|
||||
const images = await imagesConfig();
|
||||
if (images.unoptimized !== true) return;
|
||||
expect(importers(NEXT_IMAGE_IMPORT)).toEqual([]);
|
||||
});
|
||||
|
||||
it("is never on with SVG input allowed", async () => {
|
||||
const images = await imagesConfig();
|
||||
if (images.unoptimized === true) return;
|
||||
expect(images.dangerouslyAllowSVG ?? false).toBe(false);
|
||||
});
|
||||
|
||||
it("is off today", async () => {
|
||||
// The two cases above each skip on the other branch. This pins which
|
||||
// branch the tree is on, so flipping it is a visible change to this
|
||||
// file and not a silent switch between the two.
|
||||
expect((await imagesConfig()).unoptimized).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -117,16 +117,19 @@ describe("the shell owns the safe area", () => {
|
||||
// Sonner's own rules are unlayered; the toast lift must stay so.
|
||||
expect(layer).not.toMatch(/data-sonner-toaster/);
|
||||
|
||||
const sheet = code(read("src/components/ui/sheet.tsx"));
|
||||
const bottomBranch = sheet.match(/side === "bottom" &&\s*"([^"]*)"/)?.[1];
|
||||
expect(bottomBranch).toBeDefined();
|
||||
expect(bottomBranch).not.toMatch(/(?<![\w-])bottom-/);
|
||||
const sheet = code(read("src/components/ui/bottom-sheet.tsx"));
|
||||
const bottomClass = sheet.match(/BOTTOM_SHEET_CLASS\s*=\s*"([^"]*)"/)?.[1];
|
||||
expect(bottomClass).toBeDefined();
|
||||
expect(bottomClass).not.toMatch(/(?<![\w-])bottom-/);
|
||||
});
|
||||
|
||||
it("the sheet primitive names its side for those rules", () => {
|
||||
it("the sheet primitives name their side for those rules", () => {
|
||||
expect(read("src/components/ui/sheet.tsx")).toMatch(
|
||||
/data-sheet-side=\{side\}/,
|
||||
);
|
||||
expect(read("src/components/ui/bottom-sheet.tsx")).toMatch(
|
||||
/data-sheet-side="bottom"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user