Skip to content

Commit 294c81d

Browse files
kjelkokjelko
andauthored
[feat] initial impl of apptesting (#8799)
* Basic init flow * run formatter * execute command * wip changes to call new api * more fully implement the flow with actual API interactions * add tests and run formatter * a little clean up * carry displayname through * small lint stuff * teak the operation response handling * Refactor to remove the intermediate TestDef type and parse directly to TestCaseInvocation * make sure apptesting is disabled by default * fix typo in console url * use legacy webId for console URL + some final polish * add a schema file * resolving some review comments * remove browser from yaml spec * polish and fix some of the polling output * update CHANGELOG * fix tests * revert mocha import changes * fix a broken conditional * fix typo and log invocation id * run format * format --------- Co-authored-by: kjelko <kjelko@google.com>
1 parent f72ba45 commit 294c81d

File tree

17 files changed

+874
-0
lines changed

17 files changed

+874
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Add experimental App Testing feature

schema/apptesting-yaml.json

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"additionalProperties": false,
4+
"definitions": {
5+
"testConfig": {
6+
"additionalProperties": false,
7+
"type": "object",
8+
"properties": {
9+
"route": {
10+
"type": "string",
11+
"description": "URL route appended to the test target base URL at which to start the test"
12+
}
13+
}
14+
},
15+
"testStep": {
16+
"additionalProperties": false,
17+
"properties": {
18+
"goal": {
19+
"type": "string",
20+
"description": "The goal of the test step"
21+
},
22+
"hint": {
23+
"type": "string",
24+
"description": "A hint to provide extra context to accomplish the goal"
25+
},
26+
"successCriteria": {
27+
"type": "string",
28+
"description": "Crtieria that the agent can use determine if the goal is complete"
29+
}
30+
}
31+
},
32+
"test": {
33+
"additionalProperties": false,
34+
"properties": {
35+
"name": {
36+
"type": "string",
37+
"description": "A descriptive name of the test"
38+
},
39+
"testConfig": {
40+
"$ref": "#/definitions/testConfig",
41+
"description": "Configs to apply to the specific test"
42+
},
43+
"steps": {
44+
"type": "array",
45+
"items": {
46+
"$ref": "#/definitions/testStep"
47+
}
48+
}
49+
}
50+
}
51+
},
52+
"properties": {
53+
"defaultConfig": {
54+
"$ref": "#/definitions/testConfig",
55+
"description": "Default config to apply to each test within the file"
56+
},
57+
"tests": {
58+
"type": "array",
59+
"items": {
60+
"$ref": "#/definitions/test"
61+
}
62+
}
63+
}
64+
}

src/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@ export const vertexAIOrigin = () =>
170170
export const cloudAiCompanionOrigin = () =>
171171
utils.envOverride("CLOUD_AI_COMPANION_URL", "https://cloudaicompanion.googleapis.com");
172172

173+
export const appTestingOrigin = () =>
174+
utils.envOverride("FIREBASE_APP_TESTING_URL", "https://firebaseapptesting.googleapis.com");
175+
173176
/** Gets scopes that have been set. */
174177
export function getScopes(): string[] {
175178
return Array.from(commandScopes);

src/apptesting/invokeTests.spec.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { expect } from "chai";
2+
import * as nock from "nock";
3+
import { appTestingOrigin } from "../api";
4+
import { invokeTests, pollInvocationStatus } from "./invokeTests";
5+
import { FirebaseError } from "../error";
6+
import { Browser } from "./types";
7+
8+
describe("invokeTests", () => {
9+
describe("invokeTests", () => {
10+
const projectNumber = "123456789";
11+
const appId = `1:${projectNumber}:ios:abc123def456`;
12+
13+
it("throws FirebaseError if invocation request fails", async () => {
14+
nock(appTestingOrigin())
15+
.post(`/v1alpha/projects/${projectNumber}/apps/${appId}/testInvocations:invokeTestCases`)
16+
.reply(400, { error: {} });
17+
await expect(invokeTests(appId, "https://www.example.com", [])).to.be.rejectedWith(
18+
FirebaseError,
19+
"Test invocation failed",
20+
);
21+
expect(nock.isDone()).to.be.true;
22+
});
23+
24+
it("returns operation when successful", async () => {
25+
nock(appTestingOrigin())
26+
.post(`/v1alpha/projects/${projectNumber}/apps/${appId}/testInvocations:invokeTestCases`)
27+
.reply(200, { name: "foo/bar/biz" });
28+
const operation = await invokeTests(appId, "https://www.example.com", []);
29+
expect(operation).to.eql({ name: "foo/bar/biz" });
30+
expect(nock.isDone()).to.be.true;
31+
});
32+
33+
it("builds the correct request", async () => {
34+
let requestBody;
35+
nock(appTestingOrigin())
36+
.post(
37+
`/v1alpha/projects/${projectNumber}/apps/${appId}/testInvocations:invokeTestCases`,
38+
(r) => {
39+
requestBody = r;
40+
return true;
41+
},
42+
)
43+
.reply(200, { name: "foo/bar/biz" });
44+
45+
await invokeTests(appId, "https://www.example.com", [
46+
{
47+
testCase: {
48+
startUri: "https://www.example.com",
49+
displayName: "testName1",
50+
instructions: { steps: [{ goal: "test this app", hint: "try clicking the button" }] },
51+
},
52+
testExecution: [{ config: { browser: Browser.CHROME } }],
53+
},
54+
{
55+
testCase: {
56+
startUri: "https://www.example.com",
57+
displayName: "testName2",
58+
instructions: { steps: [{ goal: "retest it", successCriteria: "a dialog appears" }] },
59+
},
60+
testExecution: [{ config: { browser: Browser.CHROME } }],
61+
},
62+
]);
63+
64+
expect(requestBody).to.eql({
65+
resource: {
66+
testCaseInvocations: [
67+
{
68+
testCase: {
69+
displayName: "testName1",
70+
instructions: {
71+
steps: [
72+
{
73+
goal: "test this app",
74+
hint: "try clicking the button",
75+
},
76+
],
77+
},
78+
startUri: "https://www.example.com",
79+
},
80+
testExecution: [
81+
{
82+
config: {
83+
browser: "CHROME",
84+
},
85+
},
86+
],
87+
},
88+
{
89+
testCase: {
90+
displayName: "testName2",
91+
instructions: {
92+
steps: [
93+
{
94+
goal: "retest it",
95+
successCriteria: "a dialog appears",
96+
},
97+
],
98+
},
99+
startUri: "https://www.example.com",
100+
},
101+
testExecution: [
102+
{
103+
config: {
104+
browser: "CHROME",
105+
},
106+
},
107+
],
108+
},
109+
],
110+
testInvocation: {},
111+
},
112+
});
113+
});
114+
});
115+
116+
describe("pollInvocationStatus", () => {
117+
const operationName = "operations/foo/bar";
118+
119+
beforeEach(() => {
120+
nock(appTestingOrigin())
121+
.get(`/v1alpha/${operationName}`)
122+
.reply(200, { done: false, metadata: { count: 1 } });
123+
nock(appTestingOrigin())
124+
.get(`/v1alpha/${operationName}`)
125+
.reply(200, { done: false, metadata: { count: 2 } });
126+
nock(appTestingOrigin())
127+
.get(`/v1alpha/${operationName}`)
128+
.reply(200, { done: true, metadata: { count: 3 }, response: { foo: "12" } });
129+
});
130+
131+
it("calls poll callback with metadata on each poll", async () => {
132+
const pollResponses: { [k: string]: any }[] = [];
133+
await pollInvocationStatus(
134+
operationName,
135+
(op) => {
136+
pollResponses.push(op.metadata!);
137+
},
138+
/* backoff= */ 1,
139+
);
140+
141+
expect(pollResponses).to.eql([{ count: 1 }, { count: 2 }, { count: 3 }]);
142+
expect(nock.isDone()).to.be.true;
143+
});
144+
145+
it("returns the response", async () => {
146+
const response = await pollInvocationStatus(operationName, () => null, /* backoff= */ 1);
147+
148+
expect(response).to.eql({ foo: "12" });
149+
expect(nock.isDone()).to.be.true;
150+
});
151+
});
152+
});

src/apptesting/invokeTests.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Client } from "../apiv2";
2+
import { appTestingOrigin } from "../api";
3+
import {
4+
InvokedTestCases,
5+
InvokeTestCasesRequest,
6+
TestCaseInvocation,
7+
TestInvocation,
8+
} from "./types";
9+
import * as operationPoller from "../operation-poller";
10+
import { FirebaseError, getError } from "../error";
11+
12+
const apiClient = new Client({ urlPrefix: appTestingOrigin(), apiVersion: "v1alpha" });
13+
14+
export async function invokeTests(appId: string, startUri: string, testDefs: TestCaseInvocation[]) {
15+
const appResource = `projects/${appId.split(":")[1]}/apps/${appId}`;
16+
try {
17+
const invocationResponse = await apiClient.post<
18+
InvokeTestCasesRequest,
19+
operationPoller.LongRunningOperation<TestInvocation>
20+
>(`${appResource}/testInvocations:invokeTestCases`, buildInvokeTestCasesRequest(testDefs));
21+
return invocationResponse.body;
22+
} catch (err: unknown) {
23+
throw new FirebaseError("Test invocation failed", { original: getError(err) });
24+
}
25+
}
26+
27+
function buildInvokeTestCasesRequest(
28+
testCaseInvocations: TestCaseInvocation[],
29+
): InvokeTestCasesRequest {
30+
return {
31+
resource: {
32+
testInvocation: {},
33+
testCaseInvocations,
34+
},
35+
};
36+
}
37+
38+
interface InvocationOperation {
39+
resource: InvokedTestCases;
40+
}
41+
42+
export async function pollInvocationStatus(
43+
operationName: string,
44+
onPoll: (invocation: operationPoller.OperationResult<InvocationOperation>) => void,
45+
backoff = 30 * 1000,
46+
): Promise<InvocationOperation> {
47+
return operationPoller.pollOperation<InvocationOperation>({
48+
pollerName: "App Testing Invocation Poller",
49+
apiOrigin: appTestingOrigin(),
50+
apiVersion: "v1alpha",
51+
operationResourceName: operationName,
52+
masterTimeout: 30 * 60 * 1000, // 30 minutes
53+
backoff,
54+
maxBackoff: 15 * 1000, // 30 seconds
55+
onPoll,
56+
});
57+
}

0 commit comments

Comments
 (0)