Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Root out legacy upload APIs from the test-utils reporter #606

Merged
merged 13 commits into from
Jul 11, 2024
6 changes: 4 additions & 2 deletions packages/cypress/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,12 @@ const plugin = (
) => {
setUserAgent(`${packageName}/${packageVersion}`);

const accessToken = getAuthKey(config);

logger.initialize(packageName, packageVersion);
logger.identify(getAuthKey(config));
logger.identify(accessToken);
mixpanelAPI.initialize({
accessToken: getAuthKey(config),
accessToken,
packageName,
packageVersion,
});
Expand Down
8 changes: 5 additions & 3 deletions packages/playwright/src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,16 @@ export default class ReplayPlaywrightReporter implements Reporter {
constructor(config: ReplayPlaywrightConfig) {
setUserAgent(`${packageName}/${packageVersion}`);

const accessToken = getAccessToken(config);

logger.initialize(packageName, packageVersion);
logger.identify(getAccessToken(config));
logger.identify(accessToken);

mixpanelAPI.initialize({
accessToken: getAccessToken(config),
accessToken,
packageName,
packageVersion,
});

if (!config || typeof config !== "object") {
mixpanelAPI.trackEvent("error.invalid-reporter-config", { config });

Expand Down
4 changes: 2 additions & 2 deletions packages/replayio/src/commands/record.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { ProcessError } from "@replay-cli/shared/ProcessError";
import { logger } from "@replay-cli/shared/logger";
import { mixpanelAPI } from "@replay-cli/shared/mixpanel/mixpanelAPI";
import { exitProcess } from "@replay-cli/shared/process/exitProcess";
import { canUpload } from "@replay-cli/shared/recording/canUpload";
import { getRecordings } from "@replay-cli/shared/recording/getRecordings";
import { printRecordings } from "@replay-cli/shared/recording/printRecordings";
import { selectRecordings } from "@replay-cli/shared/recording/selectRecordings";
import { LocalRecording } from "@replay-cli/shared/recording/types";
import { uploadRecordings } from "@replay-cli/shared/recording/upload/uploadRecordings";
import { dim, statusFailed } from "@replay-cli/shared/theme";
import debug from "debug";
import { v4 as uuid } from "uuid";
Expand All @@ -16,7 +16,7 @@ import { launchBrowser } from "../utils/browser/launchBrowser";
import { reportBrowserCrash } from "../utils/browser/reportBrowserCrash";
import { registerCommand } from "../utils/commander/registerCommand";
import { confirm } from "../utils/confirm";
import { logger } from "@replay-cli/shared/logger";
import { uploadRecordings } from "../utils/recordings/uploadRecordings";

registerCommand("record", { checkForRuntimeUpdate: true, requireAuthentication: true })
.argument("[url]", `URL to open (default: "about:blank")`)
Expand Down
4 changes: 2 additions & 2 deletions packages/replayio/src/commands/upload.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { exitProcess } from "@replay-cli/shared/process/exitProcess";
import { registerCommand } from "../utils/commander/registerCommand";
import { findRecordingsWithShortIds } from "@replay-cli/shared/recording/findRecordingsWithShortIds";
import { getRecordings } from "@replay-cli/shared/recording/getRecordings";
import { printRecordings } from "@replay-cli/shared/recording/printRecordings";
import { selectRecordings } from "@replay-cli/shared/recording/selectRecordings";
import { LocalRecording } from "@replay-cli/shared/recording/types";
import { uploadRecordings } from "@replay-cli/shared/recording/upload/uploadRecordings";
import { dim } from "@replay-cli/shared/theme";
import { registerCommand } from "../utils/commander/registerCommand";
import { uploadRecordings } from "../utils/recordings/uploadRecordings";

registerCommand("upload", { requireAuthentication: true })
.argument("[ids...]", `Recording ids ${dim("(comma-separated)")}`, value => value.split(","))
Expand Down
218 changes: 218 additions & 0 deletions packages/replayio/src/utils/recordings/uploadRecordings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { Deferred, STATUS_RESOLVED } from "@replay-cli/shared/async/createDeferred";
import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken";
import { disableAnimatedLog, replayAppHost } from "@replay-cli/shared/config";
import { logger } from "@replay-cli/shared/logger";
import { logUpdate } from "@replay-cli/shared/logUpdate";
import { createAsyncFunctionWithTracking } from "@replay-cli/shared/mixpanel/createAsyncFunctionWithTracking";
import { printTable } from "@replay-cli/shared/printTable";
import { exitProcess } from "@replay-cli/shared/process/exitProcess";
import {
AUTHENTICATION_REQUIRED_ERROR_CODE,
ProtocolError,
} from "@replay-cli/shared/protocol/ProtocolError";
import { canUpload } from "@replay-cli/shared/recording/canUpload";
import { formatRecording } from "@replay-cli/shared/recording/formatRecording";
import type { LocalRecording } from "@replay-cli/shared/recording/types";
import type { ProcessingBehavior } from "@replay-cli/shared/recording/upload/types";
import { createUploadWorker } from "@replay-cli/shared/recording/upload/uploadWorker";
import {
dim,
highlight,
link,
statusFailed,
statusPending,
statusSuccess,
} from "@replay-cli/shared/theme";
import { dots } from "cli-spinners";
import assert from "node:assert/strict";
import strip from "strip-ansi";

async function printDeferredRecordingActions(
bvaughn marked this conversation as resolved.
Show resolved Hide resolved
deferredActions: Deferred<boolean, LocalRecording>[],
{
renderTitle,
renderExtraColumns,
renderFailedSummary,
}: {
renderTitle: (options: { done: boolean }) => string;
renderExtraColumns: (recording: LocalRecording) => string[];
renderFailedSummary: (failedRecordings: LocalRecording[]) => string;
}
) {
let dotIndex = 0;

const print = (done = false) => {
const dot = dots.frames[++dotIndex % dots.frames.length];
const title = renderTitle({ done });
const table = printTable({
rows: deferredActions.map(deferred => {
let status = disableAnimatedLog ? "" : statusPending(dot);
if (deferred.resolution === true) {
status = statusSuccess("✔");
} else if (deferred.resolution === false) {
status = statusFailed("✘");
}
const recording = deferred.data;
const { date, duration, id, title } = formatRecording(recording);
return [status, id, title, date, duration, ...renderExtraColumns(recording)];
}),
});

logUpdate(title + "\n" + table);
};

print();

const interval = disableAnimatedLog ? undefined : setInterval(print, dots.interval);

await Promise.all(deferredActions.map(deferred => deferred.promise));

clearInterval(interval);
print(true);
logUpdate.done();

const failedActions = deferredActions.filter(deferred => deferred.status !== STATUS_RESOLVED);
if (failedActions.length > 0) {
const failedSummary = renderFailedSummary(failedActions.map(action => action.data));
console.log(statusFailed(`${failedSummary}`) + "\n");
}
}

function printViewRecordingLinks(recordings: LocalRecording[]) {
switch (recordings.length) {
case 0: {
break;
}
case 1: {
const recording = recordings[0];
const url = `${replayAppHost}/recording/${recording.id}`;

console.log("View recording at:");
console.log(link(url));
break;
}
default: {
console.log("View recording(s) at:");

for (const recording of recordings) {
const { processType, title } = formatRecording(recording);

const url = `${replayAppHost}/recording/${recording.id}`;

const formatted = processType ? `${title} ${processType}` : title;

let text = `${formatted}: ${link(url)}`;
if (strip(text).length > process.stdout.columns) {
text = `${formatted}:\n${link(url)}`;
}

console.log(text);
}
break;
}
}
}

export const uploadRecordings = createAsyncFunctionWithTracking(
async function uploadRecordings(
recordings: LocalRecording[],
{
silent = false,
...options
}: {
deleteOnSuccess?: boolean;
processingBehavior: ProcessingBehavior;
silent?: boolean;
}
) {
recordings = recordings.filter(recording => {
if (!canUpload(recording)) {
logger.debug(`Cannot upload recording ${recording.id}`, { recording });
return false;
}

return true;
});

if (recordings.length === 0) {
return [];
}

const { accessToken } = await getAccessToken();
assert(accessToken, "No access token found");
const worker = createUploadWorker({ accessToken, ...options });
const deferredActions = recordings.map(recording => worker.upload(recording));

if (!silent) {
printDeferredRecordingActions(deferredActions, {
renderTitle: ({ done }) => (done ? "Uploaded recordings" : `Uploading recordings...`),
renderExtraColumns: recording => {
let status: string | undefined;
if (recording.processingStatus) {
switch (recording.processingStatus) {
case "processing":
status = "(processing…)";
break;
case "processed":
status = "(uploaded+processed)";
break;
}
} else {
switch (recording.uploadStatus) {
case "failed":
status = "(failed)";
break;
case "uploading":
status = "(uploading…)";
break;
case "uploaded":
status = "(uploaded)";
break;
}
}
return [status ? dim(status) : ""];
},
renderFailedSummary: failedRecordings =>
`${failedRecordings.length} recording(s) did not upload successfully`,
});
}

try {
recordings = await worker.end();
} catch (error) {
if (
error instanceof ProtocolError &&
error.protocolCode === AUTHENTICATION_REQUIRED_ERROR_CODE
) {
let message = `${statusFailed("✘")} Authentication failed.`;
if (process.env.REPLAY_API_KEY || process.env.RECORD_REPLAY_API_KEY) {
const name = process.env.REPLAY_API_KEY ? "REPLAY_API_KEY" : "RECORD_REPLAY_API_KEY";
message += ` Please check your ${highlight(name)}.`;
} else {
message += ` Please try to ${highlight("replay login")} again.`;
}
console.error(message);
await exitProcess(1);
}
throw error;
}

if (!silent) {
const uploadedRecordings = recordings.filter(
recording => recording.uploadStatus === "uploaded"
);
printViewRecordingLinks(uploadedRecordings);
}

return recordings;
},
"upload.results",
recordings => {
return {
failedCount:
recordings?.filter(recording => recording.uploadStatus !== "uploaded").length ?? 0,
uploadedCount:
recordings?.filter(recording => recording.uploadStatus === "uploaded").length ?? 0,
};
}
);
9 changes: 7 additions & 2 deletions packages/shared/src/launch-darkly/getLaunchDarklyClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { LDClient, LDUser, initialize as initializeLDClient } from "launchdarkly-node-client-sdk";
import {
LDClient,
LDSingleKindContext,
initialize as initializeLDClient,
} from "launchdarkly-node-client-sdk";
import { getReplayPath } from "../getReplayPath";

let client: LDClient;
Expand All @@ -14,8 +18,9 @@ export function getLaunchDarklyClient(initialize: boolean = true) {
client = initializeLDClient(
"60ca05fb43d6f10d234bb3cf",
{
kind: "user",
anonymous: true,
} satisfies LDUser,
} satisfies LDSingleKindContext,
{
localStoragePath: getReplayPath("launchdarkly-user-cache"),
logger: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { identifyUserProfile } from "./identifyUserProfile";

export async function initLaunchDarklyFromAccessToken(
accessToken: string,
abortSignal: AbortSignal
abortSignal?: AbortSignal
) {
logger.debug("Initializing LaunchDarkly profile");

try {
const authInfo = await getAuthInfo(accessToken);

if (abortSignal.aborted) {
if (abortSignal?.aborted) {
return;
}

Expand Down
18 changes: 7 additions & 11 deletions packages/shared/src/protocol/ProtocolClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { SessionId, sessionError } from "@replayio/protocol";
import assert from "node:assert/strict";
import WebSocket from "ws";
import { Deferred, STATUS_PENDING, createDeferred } from "../async/createDeferred";
import { getAccessToken } from "../authentication/getAccessToken";
import { replayWsServer } from "../config";
import { logger } from "../logger";
import { ProtocolError } from "./ProtocolError";
Expand All @@ -22,10 +21,12 @@ export default class ProtocolClient {
private nextMessageId = 1;
private pendingCommands: Map<number, Deferred<any, CommandData>> = new Map();
private socket: WebSocket;
private accessToken: string;

constructor() {
constructor(accessToken: string) {
logger.debug(`Creating WebSocket for ${replayWsServer}`);

this.accessToken = accessToken;
this.socket = new WebSocket(replayWsServer);

this.socket.on("close", this.onSocketClose);
Expand Down Expand Up @@ -110,12 +111,12 @@ export default class ProtocolClient {

private onSocketClose = () => {
if (this.deferredAuthenticated.status === STATUS_PENDING) {
this.deferredAuthenticated.resolve(false);
this.deferredAuthenticated.reject(new Error("Socket closed before authentication completed"));
}
};

private onSocketError = (error: any) => {
logger.debug("Socket error", { error });
logger.error("ProtocolClient:Error", { error });

if (this.deferredAuthenticated.status === STATUS_PENDING) {
this.deferredAuthenticated.reject(error);
Expand Down Expand Up @@ -153,15 +154,10 @@ export default class ProtocolClient {

private onSocketOpen = async () => {
try {
const { accessToken } = await getAccessToken();
assert(accessToken, "No access token found");

await setAccessToken(this, { accessToken });

await setAccessToken(this, { accessToken: this.accessToken });
this.deferredAuthenticated.resolve(true);
} catch (error) {
logger.debug("Error authenticating", { error });

logger.error("ProtocolClient:ServerAuthFailed", { error });
this.socket.close();
this.deferredAuthenticated.reject(error as Error);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/shared/src/recording/createSettledDeferred.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createDeferred } from "../async/createDeferred";
import { logger } from "../logger";

export function createSettledDeferred<Data>(data: Data, promise: Promise<void>) {
export function createSettledDeferred<Data>(data: Data, task: () => Promise<void>) {
const deferred = createDeferred<boolean, Data>(data);

promise.then(
task().then(
() => {
deferred.resolve(true);
},
Expand Down
Loading