@specific.dev/spectest 0.41.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ import type { ProviderOptions } from "./emulate/service.js";
2
+ export type GithubOptions = ProviderOptions;
3
+ /**
4
+ * GitHub, answering at its real endpoints. Drop into
5
+ * `environment.services`:
6
+ *
7
+ * ```ts
8
+ * import { github } from "@specific.dev/spectest/components";
9
+ *
10
+ * services: {
11
+ * github: github({
12
+ * users: [{ email: "bob@example.com", name: "Bob Example", login: "bob" }],
13
+ * client: {
14
+ * clientId: "Iv1.example",
15
+ * clientSecret: "ghs_example",
16
+ * redirectUris: ["https://app.test/callback/github"],
17
+ * },
18
+ * }),
19
+ * app: { …, dependsOn: ["github"] },
20
+ * }
21
+ * ```
22
+ *
23
+ * The app keeps its production configuration — it sends a browser to
24
+ * `https://github.com/login/oauth/authorize`, exchanges the code at
25
+ * `https://github.com/login/oauth/access_token`, and reads the account from
26
+ * `https://api.github.com/user`.
27
+ *
28
+ * The account's test id on the picker page is its **email**, even though
29
+ * GitHub identifies it by login — so a test reads the same whichever
30
+ * provider it drives.
31
+ */
32
+ export declare function github(opts?: GithubOptions): {
33
+ image: {
34
+ type: "dockerfile";
35
+ content: string;
36
+ };
37
+ command: string;
38
+ files: {
39
+ path: string;
40
+ content: string;
41
+ }[];
42
+ env: {
43
+ SPECTEST_AUTH_CONFIG: string;
44
+ };
45
+ ports: number[];
46
+ readyCheck: {
47
+ type: "http";
48
+ port: number;
49
+ path: string;
50
+ timeoutSecs: number;
51
+ };
52
+ };
@@ -0,0 +1,90 @@
1
+ // `github()` — GitHub, emulated inside the environment, answering at its
2
+ // real endpoints.
3
+ //
4
+ // Today that is Sign in with GitHub — the authorize page, the token
5
+ // exchange, and the profile reads every GitHub sign-in makes (`/user`,
6
+ // `/user/emails`). The component is named for the provider rather than for
7
+ // the feature: the backing emulator carries most of the REST API — repos,
8
+ // issues, pull requests, checks, webhooks — and reaching it is a matter of
9
+ // what this component chooses to expose, not of new plumbing.
10
+ //
11
+ // Landmine for whoever extends this: the claim is env-wide. `github.com`
12
+ // and `api.github.com` stop reaching the internet for every container in
13
+ // the environment, so anything else that talks to GitHub — a build step
14
+ // that clones a repository, an unrelated API call — reaches the emulator
15
+ // and gets a 404 outside the surface it serves.
16
+ import { emulatorService, localPart, resolveUsers } from "./emulate/service.js";
17
+ function spec(users, opts) {
18
+ const client = opts.client;
19
+ return {
20
+ name: "github",
21
+ module: "@emulators/github",
22
+ pluginExport: "githubPlugin",
23
+ baseUrl: "https://github.com",
24
+ // api.github.com cannot be avoided: every GitHub sign-in reads the
25
+ // profile from /user and the addresses from /user/emails.
26
+ hosts: ["github.com", "api.github.com"],
27
+ // OAuth 2.0, not OIDC — there is no id_token to re-issue and no JWKS.
28
+ oidc: false,
29
+ // GitHub identifies an account by login, not by address.
30
+ aliases: Object.fromEntries(users.map((u) => [u.login ?? localPart(u.email), u.email])),
31
+ fallbackUser: {
32
+ login: users[0].login ?? localPart(users[0].email),
33
+ id: 1,
34
+ scopes: ["user", "user:email"],
35
+ },
36
+ seed: {
37
+ users: users.map((u) => ({
38
+ login: u.login ?? localPart(u.email),
39
+ name: u.name,
40
+ email: u.email,
41
+ })),
42
+ // GitHub calls them oauth_apps, and its own field is `oauth_apps`.
43
+ ...(client
44
+ ? {
45
+ oauth_apps: [
46
+ {
47
+ client_id: client.clientId,
48
+ client_secret: client.clientSecret,
49
+ name: "spectest",
50
+ redirect_uris: client.redirectUris ?? [],
51
+ },
52
+ ],
53
+ }
54
+ : {}),
55
+ },
56
+ };
57
+ }
58
+ /**
59
+ * GitHub, answering at its real endpoints. Drop into
60
+ * `environment.services`:
61
+ *
62
+ * ```ts
63
+ * import { github } from "@specific.dev/spectest/components";
64
+ *
65
+ * services: {
66
+ * github: github({
67
+ * users: [{ email: "bob@example.com", name: "Bob Example", login: "bob" }],
68
+ * client: {
69
+ * clientId: "Iv1.example",
70
+ * clientSecret: "ghs_example",
71
+ * redirectUris: ["https://app.test/callback/github"],
72
+ * },
73
+ * }),
74
+ * app: { …, dependsOn: ["github"] },
75
+ * }
76
+ * ```
77
+ *
78
+ * The app keeps its production configuration — it sends a browser to
79
+ * `https://github.com/login/oauth/authorize`, exchanges the code at
80
+ * `https://github.com/login/oauth/access_token`, and reads the account from
81
+ * `https://api.github.com/user`.
82
+ *
83
+ * The account's test id on the picker page is its **email**, even though
84
+ * GitHub identifies it by login — so a test reads the same whichever
85
+ * provider it drives.
86
+ */
87
+ export function github(opts = {}) {
88
+ const users = resolveUsers("github", opts.users);
89
+ return emulatorService(spec(users, opts));
90
+ }
@@ -0,0 +1,57 @@
1
+ import type { ProviderOptions } from "./emulate/service.js";
2
+ export type GoogleOptions = ProviderOptions;
3
+ /**
4
+ * Google, answering at its real endpoints. Drop into
5
+ * `environment.services`:
6
+ *
7
+ * ```ts
8
+ * import { google } from "@specific.dev/spectest/components";
9
+ *
10
+ * services: {
11
+ * // Accounts default to Alice and Bob Example; declare `users` to
12
+ * // choose your own. A `client` makes the environment validate the
13
+ * // app's client id, secret and callback URL.
14
+ * google: google({
15
+ * client: {
16
+ * clientId: "1234.apps.googleusercontent.com",
17
+ * clientSecret: "GOCSPX-test",
18
+ * redirectUris: ["https://app.test/callback/google"],
19
+ * },
20
+ * }),
21
+ * app: { …, dependsOn: ["google"] },
22
+ * }
23
+ * ```
24
+ *
25
+ * The app keeps its production configuration — it sends a browser to
26
+ * `https://accounts.google.com/o/oauth2/v2/auth`, exchanges the code at
27
+ * `https://oauth2.googleapis.com/token`, and verifies the id_token against
28
+ * `https://www.googleapis.com/oauth2/v3/certs`, exactly as it does live.
29
+ *
30
+ * A test signs in the way a user does, through the browser:
31
+ *
32
+ * ```ts
33
+ * await page.getByTestId("login-google").click();
34
+ * await page.getByTestId("spectest-user-alice@example.com").click();
35
+ * ```
36
+ */
37
+ export declare function google(opts?: GoogleOptions): {
38
+ image: {
39
+ type: "dockerfile";
40
+ content: string;
41
+ };
42
+ command: string;
43
+ files: {
44
+ path: string;
45
+ content: string;
46
+ }[];
47
+ env: {
48
+ SPECTEST_AUTH_CONFIG: string;
49
+ };
50
+ ports: number[];
51
+ readyCheck: {
52
+ type: "http";
53
+ port: number;
54
+ path: string;
55
+ timeoutSecs: number;
56
+ };
57
+ };
@@ -0,0 +1,92 @@
1
+ // `google()` — Google, emulated inside the environment, answering at its
2
+ // real endpoints.
3
+ //
4
+ // Today that is Sign in with Google: OpenID Connect discovery, the account
5
+ // picker, PKCE, the token exchange, refresh, and userinfo. The component is
6
+ // named for the provider rather than for the feature, because the backing
7
+ // emulator also carries Gmail, Calendar and Drive — claiming
8
+ // `gmail.googleapis.com` and routing it here is a table entry, not a new
9
+ // design.
10
+ import { emulatorService, familyName, givenName, oauthClients, resolveUsers, } from "./emulate/service.js";
11
+ function spec(users, opts) {
12
+ return {
13
+ name: "google",
14
+ module: "@emulators/google",
15
+ pluginExport: "googlePlugin",
16
+ baseUrl: "https://accounts.google.com",
17
+ hosts: [
18
+ "accounts.google.com",
19
+ "oauth2.googleapis.com",
20
+ "www.googleapis.com",
21
+ "openidconnect.googleapis.com",
22
+ ],
23
+ discovery: {
24
+ issuer: "https://accounts.google.com",
25
+ authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth",
26
+ token_endpoint: "https://oauth2.googleapis.com/token",
27
+ userinfo_endpoint: "https://openidconnect.googleapis.com/v1/userinfo",
28
+ revocation_endpoint: "https://oauth2.googleapis.com/revoke",
29
+ jwks_uri: "https://www.googleapis.com/oauth2/v3/certs",
30
+ },
31
+ jwksPath: "^/oauth2/v3/certs$",
32
+ rewrites: [
33
+ // oauth2.googleapis.com serves these at the root; the emulator puts
34
+ // them under /oauth2.
35
+ { from: "^/token$", to: "/oauth2/token" },
36
+ { from: "^/revoke$", to: "/oauth2/revoke" },
37
+ // openidconnect.googleapis.com and the v3 alias both mean userinfo.
38
+ { from: "^/v1/userinfo$", to: "/oauth2/v2/userinfo" },
39
+ { from: "^/oauth2/v3/userinfo$", to: "/oauth2/v2/userinfo" },
40
+ ],
41
+ fallbackUser: { login: users[0].email, id: 1, scopes: ["openid", "email", "profile"] },
42
+ seed: {
43
+ users: users.map((u) => ({
44
+ email: u.email,
45
+ name: u.name,
46
+ given_name: givenName(u),
47
+ family_name: familyName(u),
48
+ picture: u.picture,
49
+ email_verified: true,
50
+ })),
51
+ ...oauthClients(opts.client),
52
+ },
53
+ };
54
+ }
55
+ /**
56
+ * Google, answering at its real endpoints. Drop into
57
+ * `environment.services`:
58
+ *
59
+ * ```ts
60
+ * import { google } from "@specific.dev/spectest/components";
61
+ *
62
+ * services: {
63
+ * // Accounts default to Alice and Bob Example; declare `users` to
64
+ * // choose your own. A `client` makes the environment validate the
65
+ * // app's client id, secret and callback URL.
66
+ * google: google({
67
+ * client: {
68
+ * clientId: "1234.apps.googleusercontent.com",
69
+ * clientSecret: "GOCSPX-test",
70
+ * redirectUris: ["https://app.test/callback/google"],
71
+ * },
72
+ * }),
73
+ * app: { …, dependsOn: ["google"] },
74
+ * }
75
+ * ```
76
+ *
77
+ * The app keeps its production configuration — it sends a browser to
78
+ * `https://accounts.google.com/o/oauth2/v2/auth`, exchanges the code at
79
+ * `https://oauth2.googleapis.com/token`, and verifies the id_token against
80
+ * `https://www.googleapis.com/oauth2/v3/certs`, exactly as it does live.
81
+ *
82
+ * A test signs in the way a user does, through the browser:
83
+ *
84
+ * ```ts
85
+ * await page.getByTestId("login-google").click();
86
+ * await page.getByTestId("spectest-user-alice@example.com").click();
87
+ * ```
88
+ */
89
+ export function google(opts = {}) {
90
+ const users = resolveUsers("google", opts.users);
91
+ return emulatorService(spec(users, opts));
92
+ }
@@ -6,4 +6,10 @@ export { expo, type ExpoOptions, type ExpoHelpers, } from "./expo.js";
6
6
  export { supabase, type SupabaseOptions, type SupabaseHelpers, type SupabaseStack, } from "./supabase.js";
7
7
  export { email, type EmailOptions, type EmailHelpers, type EmailMessage, type EmailSummary, type EmailMatch, type EmailAttachment, } from "./email.js";
8
8
  export { aws, type AwsOptions, type LambdaOptions, } from "./aws.js";
9
+ export { type ProviderOptions, type ProviderUser, type OAuthClient, } from "./emulate/service.js";
10
+ export { google, type GoogleOptions } from "./google.js";
11
+ export { github, type GithubOptions } from "./github.js";
12
+ export { apple, type AppleOptions } from "./apple.js";
13
+ export { microsoft, type MicrosoftOptions } from "./microsoft.js";
14
+ export { okta, type OktaOptions } from "./okta.js";
9
15
  export { replayFake, type ReplayFakeOptions, type ReplayMatch, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
@@ -16,4 +16,9 @@ export { expo, } from "./expo.js";
16
16
  export { supabase, } from "./supabase.js";
17
17
  export { email, } from "./email.js";
18
18
  export { aws, } from "./aws.js";
19
+ export { google } from "./google.js";
20
+ export { github } from "./github.js";
21
+ export { apple } from "./apple.js";
22
+ export { microsoft } from "./microsoft.js";
23
+ export { okta } from "./okta.js";
19
24
  export { replayFake, } from "./replayFake.js";
@@ -0,0 +1,55 @@
1
+ import type { ProviderOptions } from "./emulate/service.js";
2
+ export interface MicrosoftOptions extends ProviderOptions {
3
+ /**
4
+ * Directory (tenant) the app signs in against — whatever its authority
5
+ * URL uses. It appears in the issuer and in every endpoint path, so a
6
+ * client that checks the issuer sees what it expects. Default `"common"`.
7
+ */
8
+ tenantId?: string;
9
+ }
10
+ /**
11
+ * Microsoft, answering at its real endpoints. Drop into
12
+ * `environment.services`:
13
+ *
14
+ * ```ts
15
+ * import { microsoft } from "@specific.dev/spectest/components";
16
+ *
17
+ * services: {
18
+ * microsoft: microsoft({
19
+ * tenantId: "11111111-2222-3333-4444-555555555555",
20
+ * users: [{ email: "bob@example.com", name: "Bob Example" }],
21
+ * client: {
22
+ * clientId: "6f1a2b3c-…",
23
+ * clientSecret: "secret",
24
+ * redirectUris: ["https://app.test/callback/microsoft"],
25
+ * },
26
+ * }),
27
+ * app: { …, dependsOn: ["microsoft"] },
28
+ * }
29
+ * ```
30
+ *
31
+ * The app keeps its production configuration — discovery at
32
+ * `https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration`,
33
+ * and the account read back from `https://graph.microsoft.com/v1.0/me`.
34
+ */
35
+ export declare function microsoft(opts?: MicrosoftOptions): {
36
+ image: {
37
+ type: "dockerfile";
38
+ content: string;
39
+ };
40
+ command: string;
41
+ files: {
42
+ path: string;
43
+ content: string;
44
+ }[];
45
+ env: {
46
+ SPECTEST_AUTH_CONFIG: string;
47
+ };
48
+ ports: number[];
49
+ readyCheck: {
50
+ type: "http";
51
+ port: number;
52
+ path: string;
53
+ timeoutSecs: number;
54
+ };
55
+ };
@@ -0,0 +1,76 @@
1
+ // `microsoft()` — Microsoft, emulated inside the environment, answering at
2
+ // its real endpoints.
3
+ //
4
+ // Today that is Sign in with Microsoft (Entra ID) plus the part of Graph an
5
+ // app reads straight after: discovery, the account picker, the token
6
+ // exchange, `/oidc/userinfo` and `/v1.0/me`. The component is named for the
7
+ // provider rather than for the feature — Graph is already claimed, and
8
+ // widening what it serves is a table entry.
9
+ import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.js";
10
+ function spec(users, opts) {
11
+ const authority = `https://login.microsoftonline.com/${opts.tenantId || "common"}`;
12
+ return {
13
+ name: "microsoft",
14
+ module: "@emulators/microsoft",
15
+ pluginExport: "microsoftPlugin",
16
+ baseUrl: "https://login.microsoftonline.com",
17
+ hosts: ["login.microsoftonline.com", "graph.microsoft.com"],
18
+ // The emulator has no tenant, so its issuer is the bare host. A client
19
+ // that checks the issuer against the authority it configured would
20
+ // reject that; the re-issue step corrects it.
21
+ issuer: `${authority}/v2.0`,
22
+ discovery: {
23
+ issuer: `${authority}/v2.0`,
24
+ authorization_endpoint: `${authority}/oauth2/v2.0/authorize`,
25
+ token_endpoint: `${authority}/oauth2/v2.0/token`,
26
+ jwks_uri: `${authority}/discovery/v2.0/keys`,
27
+ end_session_endpoint: `${authority}/oauth2/v2.0/logout`,
28
+ userinfo_endpoint: "https://graph.microsoft.com/oidc/userinfo",
29
+ },
30
+ jwksPath: "^/discovery/v2\\.0/keys$",
31
+ rewrites: [
32
+ // Real Entra paths carry the tenant; the emulator's do not. The
33
+ // discovery route is the exception — it handles the tenant itself.
34
+ { from: "^/[^/]+/(oauth2/v2\\.0/.*)$", to: "/$1" },
35
+ { from: "^/[^/]+/(discovery/v2\\.0/keys)$", to: "/$1" },
36
+ ],
37
+ fallbackUser: {
38
+ login: users[0].email,
39
+ id: 1,
40
+ scopes: ["openid", "email", "profile", "User.Read"],
41
+ },
42
+ seed: {
43
+ users: users.map((u) => ({ email: u.email, name: u.name })),
44
+ ...oauthClients(opts.client),
45
+ },
46
+ };
47
+ }
48
+ /**
49
+ * Microsoft, answering at its real endpoints. Drop into
50
+ * `environment.services`:
51
+ *
52
+ * ```ts
53
+ * import { microsoft } from "@specific.dev/spectest/components";
54
+ *
55
+ * services: {
56
+ * microsoft: microsoft({
57
+ * tenantId: "11111111-2222-3333-4444-555555555555",
58
+ * users: [{ email: "bob@example.com", name: "Bob Example" }],
59
+ * client: {
60
+ * clientId: "6f1a2b3c-…",
61
+ * clientSecret: "secret",
62
+ * redirectUris: ["https://app.test/callback/microsoft"],
63
+ * },
64
+ * }),
65
+ * app: { …, dependsOn: ["microsoft"] },
66
+ * }
67
+ * ```
68
+ *
69
+ * The app keeps its production configuration — discovery at
70
+ * `https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration`,
71
+ * and the account read back from `https://graph.microsoft.com/v1.0/me`.
72
+ */
73
+ export function microsoft(opts = {}) {
74
+ const users = resolveUsers("microsoft", opts.users);
75
+ return emulatorService(spec(users, opts));
76
+ }
@@ -0,0 +1,51 @@
1
+ import type { ProviderOptions } from "./emulate/service.js";
2
+ export interface OktaOptions extends ProviderOptions {
3
+ /** The org's domain, e.g. `"dev-12345.okta.com"`. */
4
+ domain: string;
5
+ }
6
+ /**
7
+ * Okta, answering at your org's real domain. Drop into
8
+ * `environment.services`:
9
+ *
10
+ * ```ts
11
+ * import { okta } from "@specific.dev/spectest/components";
12
+ *
13
+ * services: {
14
+ * okta: okta({
15
+ * domain: "dev-12345.okta.com",
16
+ * users: [{ email: "alice@example.com", name: "Alice Example" }],
17
+ * client: {
18
+ * clientId: "0oaexample",
19
+ * clientSecret: "secret",
20
+ * redirectUris: ["https://app.test/callback/okta"],
21
+ * },
22
+ * }),
23
+ * app: { …, dependsOn: ["okta"] },
24
+ * }
25
+ * ```
26
+ *
27
+ * The app keeps its production configuration — discovery at
28
+ * `https://dev-12345.okta.com/.well-known/openid-configuration`, and
29
+ * everything it names.
30
+ */
31
+ export declare function okta(opts: OktaOptions): {
32
+ image: {
33
+ type: "dockerfile";
34
+ content: string;
35
+ };
36
+ command: string;
37
+ files: {
38
+ path: string;
39
+ content: string;
40
+ }[];
41
+ env: {
42
+ SPECTEST_AUTH_CONFIG: string;
43
+ };
44
+ ports: number[];
45
+ readyCheck: {
46
+ type: "http";
47
+ port: number;
48
+ path: string;
49
+ timeoutSecs: number;
50
+ };
51
+ };
@@ -0,0 +1,78 @@
1
+ // `okta()` — Okta, emulated inside the environment, answering at your org's
2
+ // real domain.
3
+ //
4
+ // Today that is Okta sign-in: discovery, the account picker, the token
5
+ // exchange, userinfo, introspection and revocation, on both the org
6
+ // authorization server (`/oauth2/v1/…`) and a custom one
7
+ // (`/oauth2/default/…`). The component is named for the provider rather
8
+ // than for the feature — the backing emulator also carries the users,
9
+ // groups and apps management API.
10
+ //
11
+ // Okta has no fixed hostname: every organization gets its own domain, so
12
+ // `domain` is required where the other providers need nothing.
13
+ import { emulatorService, familyName, givenName, oauthClients, resolveUsers, } from "./emulate/service.js";
14
+ /** Okta's picker identifies an account by its opaque Okta id, so the id has
15
+ * to be seeded rather than generated — otherwise nothing on the page can be
16
+ * mapped back to an address. */
17
+ function oktaId(email) {
18
+ return `u-${email.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
19
+ }
20
+ function spec(users, opts) {
21
+ const domain = opts.domain.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
22
+ return {
23
+ name: "okta",
24
+ module: "@emulators/okta",
25
+ pluginExport: "oktaPlugin",
26
+ baseUrl: `https://${domain}`,
27
+ hosts: [domain],
28
+ // Okta's issuer is per authorization server and the emulator already
29
+ // builds it from the base URL, which here is the real org domain.
30
+ jwksPath: "^/oauth2/(?:[^/]+/)?v1/keys$",
31
+ aliases: Object.fromEntries(users.map((u) => [oktaId(u.email), u.email])),
32
+ fallbackUser: { login: users[0].email, id: 1, scopes: ["openid", "profile", "email"] },
33
+ seed: {
34
+ users: users.map((u) => ({
35
+ okta_id: oktaId(u.email),
36
+ // An Okta login is the address, not the GitHub-style handle that
37
+ // `login` carries — it comes back as `preferred_username`.
38
+ login: u.email,
39
+ email: u.email,
40
+ first_name: givenName(u),
41
+ last_name: familyName(u),
42
+ display_name: u.name,
43
+ })),
44
+ ...oauthClients(opts.client),
45
+ },
46
+ };
47
+ }
48
+ /**
49
+ * Okta, answering at your org's real domain. Drop into
50
+ * `environment.services`:
51
+ *
52
+ * ```ts
53
+ * import { okta } from "@specific.dev/spectest/components";
54
+ *
55
+ * services: {
56
+ * okta: okta({
57
+ * domain: "dev-12345.okta.com",
58
+ * users: [{ email: "alice@example.com", name: "Alice Example" }],
59
+ * client: {
60
+ * clientId: "0oaexample",
61
+ * clientSecret: "secret",
62
+ * redirectUris: ["https://app.test/callback/okta"],
63
+ * },
64
+ * }),
65
+ * app: { …, dependsOn: ["okta"] },
66
+ * }
67
+ * ```
68
+ *
69
+ * The app keeps its production configuration — discovery at
70
+ * `https://dev-12345.okta.com/.well-known/openid-configuration`, and
71
+ * everything it names.
72
+ */
73
+ export function okta(opts) {
74
+ const users = resolveUsers("okta", opts.users);
75
+ if (!opts.domain)
76
+ throw new Error("okta(): `domain` is required (e.g. \"dev-12345.okta.com\")");
77
+ return emulatorService(spec(users, opts));
78
+ }