mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-28 15:46:58 +09:00
Transition variables no longer get copied into unrelated timing fields. Generated Text/Stagger animations use the saved `slidingWindow` prop, while older `charWindow` configurations still work. Easing moves in the expected direction. Windows preview-install reports include the process failure code when available. Closes #6598 Ref #6593 Ref #6599 Ref #6594 The animation runtime lives in [sdk-components-animation #38](https://github.com/webstudio-is/sdk-components-animation/pull/38). The private PR is merged; this PR points to its merged commit. #6593 was tested on its exact saved project in headless Chromium and closed as not reproducible; the confirmed prop leak is fixed here. #6594 was closed after a documented local repro attempt; this PR improves the next report. ## Work - [x] Reproduce the transition bug; add regression coverage in parsing and generated CSS. - [x] Preserve unresolved `transition` shorthands for the browser to resolve. - [x] Keep old `charWindow` values working in Text and Stagger animations, with a red/green regression test in the linked private PR. - [x] Reproduce and fix the saved animation prop mismatch in generated helpers. - [x] Verify text/stagger easing in unit tests and Chromium/Firefox. - [x] Add safe preview-install process details for future Windows reports. - [x] Update Builder property help and both animation docs pages. - [x] Run focused animation tests, typecheck, lint, package build, and animation fixture build after the compatibility fix. - [x] Point the animation submodule to the merged #38 commit. - [x] Verify required CI on the final commit (all workflows passed; one unrelated E2E shard passed on rerun). - [x] Find the production share token and build #6593’s exact project with its reported 0.307.0 packages. - [x] Compare intermediate scroll styles on #6593’s exact project. Visible targets animate in both builds; the leaked attributes disappear with this fix. Known limit: the reported static-target symptom in #6593 and the Windows environment in #6594 were not reproduced. The confirmed animation prop leak and reporting gap are fixed, without claiming the unreproduced symptoms are solved.
344 lines
9.5 KiB
TypeScript
344 lines
9.5 KiB
TypeScript
import { createSign } from "node:crypto";
|
|
import type {
|
|
IssueReportInput,
|
|
IssueReportResult,
|
|
} from "@webstudio-is/protocol";
|
|
import env from "~/env/env.server";
|
|
|
|
type GitHubIssue = {
|
|
number: number;
|
|
html_url: string;
|
|
};
|
|
|
|
type GitHubSearchResponse = {
|
|
items?: GitHubIssue[];
|
|
};
|
|
|
|
const githubApiVersion = "2022-11-28";
|
|
const reportRepository = "webstudio-is/webstudio";
|
|
|
|
const getReportMarker = (deduplicationKey: string) =>
|
|
`<!-- webstudio-issue-report:${deduplicationKey} -->`;
|
|
|
|
const formatItems = (items: readonly string[]) =>
|
|
items.map((item, index) => `${index + 1}. ${item}`).join("\n");
|
|
|
|
const formatRuntime = (runtime: IssueReportInput["runtime"]) =>
|
|
[
|
|
"## Technical runtime",
|
|
"",
|
|
`- CLI: ${runtime?.cliVersion ?? "unknown"}`,
|
|
`- Node.js: ${runtime?.nodeVersion ?? "unknown"}`,
|
|
...(runtime?.projectId === undefined
|
|
? []
|
|
: [`- Project ID: \`${runtime.projectId}\``]),
|
|
...(runtime?.recentTargets === undefined
|
|
? []
|
|
: runtime.recentTargets.flatMap(({ tool, entityIds }) => [
|
|
`- Recent targeted tool: \`${tool}\``,
|
|
...entityIds.map(({ field, id }) => ` - ${field}: \`${id}\``),
|
|
])),
|
|
...(runtime === undefined
|
|
? []
|
|
: [
|
|
`- Operating system: ${runtime.os} ${runtime.osVersion} (${runtime.architecture})`,
|
|
`- Execution mode: ${runtime.executionMode}`,
|
|
`- API contract: ${runtime.apiContractVersion}`,
|
|
...(runtime.serverApiContractVersion === undefined
|
|
? []
|
|
: [`- Server API contract: ${runtime.serverApiContractVersion}`]),
|
|
`- Bundle contract: ${runtime.bundleVersion ?? "unknown"}`,
|
|
]),
|
|
...(runtime?.session === undefined
|
|
? []
|
|
: [
|
|
`- Session namespace issues: stale=${runtime.session.staleNamespaces.join(",") || "none"}; missing=${runtime.session.missingNamespaces.join(",") || "none"}; invalidated=${runtime.session.invalidatedNamespaces.join(",") || "none"}`,
|
|
]),
|
|
...(runtime?.preview === undefined
|
|
? []
|
|
: [
|
|
`- Preview: stale=${runtime.preview.stale}; rendered=${runtime.preview.hasRenderedVersion}; version matches session=${runtime.preview.renderedVersionMatchesSession ?? "unknown"}`,
|
|
]),
|
|
].join("\n");
|
|
|
|
const formatFailureDiagnostics = (runtime: IssueReportInput["runtime"]) => {
|
|
const failure = runtime?.recentFailure;
|
|
if (failure === undefined) {
|
|
return "";
|
|
}
|
|
const issues = failure.issues ?? [];
|
|
const previewInstall = failure.previewInstall;
|
|
const previewInstallDetails = [
|
|
previewInstall?.processErrorCode,
|
|
previewInstall?.exitCode === undefined
|
|
? undefined
|
|
: `exit code ${previewInstall.exitCode}`,
|
|
previewInstall?.terminated ? "process terminated" : undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join("; ");
|
|
return [
|
|
"## Captured failure diagnostics",
|
|
"",
|
|
`- Tool: \`${failure.tool}\``,
|
|
`- Error code: \`${failure.code}\``,
|
|
...(failure.errorType === undefined
|
|
? []
|
|
: [`- Error type: ${failure.errorType}`]),
|
|
...(failure.requestId === undefined
|
|
? []
|
|
: [`- Request ID: \`${failure.requestId}\``]),
|
|
...(failure.httpStatus === undefined
|
|
? []
|
|
: [`- HTTP status: \`${failure.httpStatus}\``]),
|
|
...(failure.elapsedMs === undefined
|
|
? []
|
|
: [`- Duration: \`${failure.elapsedMs}ms\``]),
|
|
...(failure.entityIds === undefined
|
|
? []
|
|
: failure.entityIds.map(({ field, id }) => `- ${field}: \`${id}\``)),
|
|
...(failure.response === undefined
|
|
? []
|
|
: [
|
|
`- Response: ${failure.response.format}; envelope=${failure.response.envelope}${failure.response.batchSize === undefined ? "" : `; batch size=${failure.response.batchSize}`}`,
|
|
]),
|
|
...(failure.browser === undefined
|
|
? []
|
|
: [
|
|
...(failure.browser.exitSignal !== undefined
|
|
? [`- Browser exit: ${failure.browser.exitSignal}`]
|
|
: failure.browser.exitCode === undefined
|
|
? []
|
|
: [`- Browser exit: code ${failure.browser.exitCode}`]),
|
|
...(failure.browser.attempts === undefined
|
|
? []
|
|
: [
|
|
`- Browser attempts: ${failure.browser.attempts.map((attempt) => `${attempt.browser} (${attempt.source})`).join(", ")}`,
|
|
]),
|
|
]),
|
|
...(previewInstallDetails === ""
|
|
? []
|
|
: [`- Preview install: ${previewInstallDetails}`]),
|
|
...(issues.length === 0
|
|
? []
|
|
: [
|
|
"- Invalid fields:",
|
|
...issues.map(
|
|
(issue) =>
|
|
` - \`${issue.path.join(".") || "input"}\`: \`${issue.code}\` (${issue.constraint})`
|
|
),
|
|
]),
|
|
"",
|
|
].join("\n");
|
|
};
|
|
|
|
export const formatIssueReport = ({
|
|
agent,
|
|
category,
|
|
deduplicationKey,
|
|
report,
|
|
runtime,
|
|
trigger,
|
|
}: IssueReportInput) => `${getReportMarker(deduplicationKey)}
|
|
|
|
## User story
|
|
|
|
${report.userStory}
|
|
|
|
## Summary
|
|
|
|
${report.summary}
|
|
|
|
## What the agent tried
|
|
|
|
${formatItems(report.attemptedWorkflow)}
|
|
|
|
## Expected behavior
|
|
|
|
${report.expectedBehavior}
|
|
|
|
## Actual result
|
|
|
|
${report.actualResult}
|
|
|
|
## Recovery attempts
|
|
|
|
${formatItems(report.recoveryAttempts)}
|
|
|
|
## User impact
|
|
|
|
${report.userImpact}
|
|
|
|
## Technical context
|
|
|
|
${report.technicalContext}
|
|
|
|
## Agent environment
|
|
|
|
- Client: ${agent.client}${agent.clientVersion === undefined ? "" : ` ${agent.clientVersion}`}
|
|
- Provider: ${agent.provider ?? "unknown"}
|
|
- Model: ${agent.model}
|
|
- Reasoning effort: ${agent.reasoningEffort}
|
|
- Trigger: ${trigger}
|
|
- Category: ${category}
|
|
|
|
${formatRuntime(runtime)}
|
|
|
|
${formatFailureDiagnostics(runtime)}
|
|
|
|
## Acceptance criteria
|
|
|
|
${formatItems(report.acceptanceCriteria)}
|
|
`;
|
|
|
|
const requestGitHub = async <Result>({
|
|
body,
|
|
method,
|
|
path,
|
|
request,
|
|
token,
|
|
}: {
|
|
body?: object;
|
|
method?: "GET" | "POST";
|
|
path: string;
|
|
request: typeof fetch;
|
|
token: string;
|
|
}) => {
|
|
const response = await request(new URL(path, "https://api.github.com"), {
|
|
method,
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${token}`,
|
|
"X-GitHub-Api-Version": githubApiVersion,
|
|
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
},
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
});
|
|
if (response.ok === false) {
|
|
throw new Error(
|
|
`GitHub issue reporting failed with HTTP ${response.status}.`
|
|
);
|
|
}
|
|
return (await response.json()) as Result;
|
|
};
|
|
|
|
const encodeJwtPart = (value: object) =>
|
|
Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
|
|
export const createGitHubAppJwt = ({
|
|
appId,
|
|
privateKey,
|
|
now = Date.now(),
|
|
}: {
|
|
appId: string;
|
|
privateKey: string;
|
|
now?: number;
|
|
}) => {
|
|
const issuedAt = Math.floor(now / 1000) - 60;
|
|
const unsignedToken = [
|
|
encodeJwtPart({ alg: "RS256", typ: "JWT" }),
|
|
encodeJwtPart({ iss: appId, iat: issuedAt, exp: issuedAt + 10 * 60 }),
|
|
].join(".");
|
|
const signer = createSign("RSA-SHA256");
|
|
signer.update(unsignedToken);
|
|
signer.end();
|
|
const signature = signer
|
|
.sign(privateKey.split("\\n").join("\n"))
|
|
.toString("base64url");
|
|
return `${unsignedToken}.${signature}`;
|
|
};
|
|
|
|
export const createGitHubInstallationToken = async ({
|
|
appId,
|
|
createJwt = createGitHubAppJwt,
|
|
privateKey,
|
|
request = fetch,
|
|
}: {
|
|
appId: string;
|
|
createJwt?: typeof createGitHubAppJwt;
|
|
privateKey: string;
|
|
request?: typeof fetch;
|
|
}) => {
|
|
const appJwt = createJwt({ appId, privateKey });
|
|
const installation = await requestGitHub<{ id?: unknown }>({
|
|
path: `/repos/${reportRepository}/installation`,
|
|
request,
|
|
token: appJwt,
|
|
});
|
|
if (typeof installation.id !== "number") {
|
|
throw new Error("GitHub App installation was not found.");
|
|
}
|
|
const result = await requestGitHub<{ token?: unknown }>({
|
|
method: "POST",
|
|
path: `/app/installations/${installation.id}/access_tokens`,
|
|
request,
|
|
token: appJwt,
|
|
});
|
|
if (typeof result.token !== "string" || result.token.length === 0) {
|
|
throw new Error("GitHub App did not return an installation token.");
|
|
}
|
|
return result.token;
|
|
};
|
|
|
|
export const publishIssueReport = async (
|
|
input: IssueReportInput,
|
|
{
|
|
getInstallationToken,
|
|
request = fetch,
|
|
}: {
|
|
getInstallationToken: () => Promise<string>;
|
|
request?: typeof fetch;
|
|
}
|
|
): Promise<IssueReportResult> => {
|
|
const token = await getInstallationToken();
|
|
const searchUrl = new URL("https://api.github.com/search/issues");
|
|
searchUrl.searchParams.set(
|
|
"q",
|
|
`repo:${reportRepository} is:issue in:body "${getReportMarker(input.deduplicationKey)}"`
|
|
);
|
|
const search = await requestGitHub<GitHubSearchResponse>({
|
|
path: `${searchUrl.pathname}${searchUrl.search}`,
|
|
request,
|
|
token,
|
|
});
|
|
const existing = search.items?.[0];
|
|
if (existing !== undefined) {
|
|
return {
|
|
status: "existing",
|
|
issueNumber: existing.number,
|
|
issueUrl: existing.html_url,
|
|
};
|
|
}
|
|
|
|
const created = await requestGitHub<GitHubIssue>({
|
|
method: "POST",
|
|
path: `/repos/${reportRepository}/issues`,
|
|
request,
|
|
token,
|
|
body: {
|
|
title: input.title,
|
|
body: formatIssueReport(input),
|
|
},
|
|
});
|
|
return {
|
|
status: "created",
|
|
issueNumber: created.number,
|
|
issueUrl: created.html_url,
|
|
};
|
|
};
|
|
|
|
export const publishConfiguredIssueReport = async (input: IssueReportInput) => {
|
|
const appId = env.GITHUB_ISSUE_REPORT_APP_ID;
|
|
const privateKey = env.GITHUB_ISSUE_REPORT_PRIVATE_KEY;
|
|
if (appId === undefined || privateKey === undefined) {
|
|
throw new Error("GitHub issue reporting is not configured.");
|
|
}
|
|
return await publishIssueReport(input, {
|
|
getInstallationToken: async () =>
|
|
await createGitHubInstallationToken({
|
|
appId,
|
|
privateKey,
|
|
}),
|
|
});
|
|
};
|