Viya emulation: a Viya-shaped surface for development and testing

Abierto
#665 0 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 1 día

Nadie ha tomado este issue todavía.

Evaluación

Dificultad
5/5
Tiempo estimado
Más de una semana
Aptitud para principiantes
20/100
Tipo de issue
Nueva funcionalidad
Claridad
Bastante claro
Estado de actividad
Activo
Stack tecnológico
typescript

Línea de trabajo

Start by reading the adapter call surface in section 3 and the proposed route table, overlay, service, token, translation, and version modules in section 4. The first deliverable is the contract capture harness and a captured pass for each listed family; inspect the generated table and corpus against the pinned version. Done means the emulator answers the adapter’s in-scope calls through existing services, with captured response shapes, explicit unsupported routes, and the emulation marker.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

enhancement

Viya emulation on sasjs/server: implementation plan

Status: proposal for approval. No implementation code exists yet.

This issue is the durable home for the plan. Decisions land here as they are taken, and the text below is amended in place rather than re-posted.
Date: 2026-11-11
Owner: [email protected] (sasjs profile agent)
Tracking issue to open: sasjs/server, "Viya emulation: a Viya-shaped surface for development and testing"

1. Purpose

This workstream exists to make development and testing of apps that deploy to a real SAS Viya estate possible without an estate: the app keeps serverType=SASVIYA, points serverUrl at an emulator, and its normal calls are answered in Viya's shape with generated data. The emulator runs anywhere the app runs, including entirely inside a browser, so a Viya-targeted app can be built, reviewed, demonstrated and regression-tested with no VPN, no credentials and no server.

It also gives adapter, CLI and app defects a deterministic reproduction that survives the fix, instead of a hand-built stub that is deleted afterwards.

2. What this is not

This is a development and testing aid for apps that ship to real Viya. It is not a Viya replacement, and it cannot become one.

  • It executes no SAS Viya engine. Compute sessions, jobs and contexts are emulated, so results are indicative of shape and contract, never of platform behaviour.
  • It implements only what the adapter calls. Anything outside that surface answers a 404 from the emulator, which is not evidence about how Viya behaves.
  • It carries no platform guarantees: no multi-tenancy, no scheduling, no security model beyond the adapter's own auth flow, no performance characteristics.
  • It must never serve production traffic, and no answer it gives is authoritative for a real deployment.
  • Every response says so, in an identifying header and in the server's own status output (section 6), precisely so an emulated answer cannot be mistaken for a real one.

Corollaries that follow from that: do not use it as a staging environment for a real deployment, do not treat emulated output as platform truth in a support case, and do not extend it to a route the adapter does not call on the grounds that Viya has one.

3. Scope: the adapter's call surface, and nothing else

The scope of the emulator is defined by the client. @sasjs/adapter builds these calls, and the emulator answers exactly these, in this shape:

auth          /SASLogon/oauth/token            getAccessTokenForViya.ts:28, refreshTokensForViya.ts:26
              /SASLogon/oauth/authorize        SASViyaApiClient.ts:761
              /SASLogon/login (GET, POST form) AuthManager.ts:175, 189
              /SASLogon/logout(.do)            AuthManager.ts:29-32
identity      /identities/users/@currentUser   AuthManager.ts:263
              /identities                      SASViyaApiClient.ts
folders       /folders/folders                 SASViyaApiClient.ts
              /folders/folders/@item           SASViyaApiClient.ts
              /folders/folders/{id}            SASViyaApiClient.ts
              /folders/folders/{id}/members    SASViyaApiClient.ts
              /folders/folders/@myRecycleBin   SASViyaApiClient.ts
files         /files/files                     SASViyaApiClient.ts
              /files/files/{id}                SASViyaApiClient.ts
              /files/files/{id}/content        SASViyaApiClient.ts
compute       /compute/contexts                SASViyaApiClient.ts
              /compute/contexts/{id}/sessions   SASViyaApiClient.ts
              /compute/sessions/{id}           SASViyaApiClient.ts
              /compute/sessions/{id}/state     SASViyaApiClient.ts
              /compute/sessions/{id}/jobs      SASViyaApiClient.ts
              /compute/sessions/{id}/jobs/{id}/state
              /compute/sessions/{id}/filerefs/_webout/content
jobs          /jobDefinitions, /jobDefinitions/definitions/{id}
              /jobExecution/jobs, /jobExecution/jobs/{id}, /jobExecution/jobs/{id}/state
              /SASJobExecution

A scope guard holds this list to the source rather than to memory: a check extracts the path literals the adapter builds and fails when the table and the adapter disagree in either direction. An adapter route the table lacks is a gap; a table entry the adapter never calls is scope the emulator should not carry.

Explicitly out, because no adapter call reaches it: CAS and dataTables, reports and visual analytics, microanalytic score, model repositories, jobDefinitions beyond the read the adapter performs, and the platform's administrative endpoints.

4. Architecture: a second route table, not a second server

The emulator is a second table inside @sasjs/api, over the same services, dispatched by the same dispatcher. Each host then owns only transport.

src/viya/table.ts         route descriptors: path, verb, params, status set, target version
src/viya/overlay.ts       implemented | declined-with-reason | hostResponses
src/viya/service.ts       handlers per family, calling the existing ports
src/viya/tokens.ts        token minting and validation for the emulated auth flow
src/viya/translate.ts     Viya shape to port shape, the only module that knows both
src/viya/version.ts       the pinned target version, and the header it stamps
src/corpus/viya/*.ts      cases, replayed by the existing runner
src/host/dispatch.ts      one more entry: dispatchViya(table, request, ports)

Family mapping, so no decision is implemented twice:

Viya /folders, /files                     -> DriveService
Viya /identities, /SASLogon/oauth/*       -> IdentityService plus the token port
Viya /compute/contexts|sessions|jobs      -> SessionService plus RuntimeService
Viya /jobExecution, /SASJobExecution      -> the runtime job path plus AppStreamRegistry
Viya /files/files/{id}/content (_webout)  -> the runtime output store

Hosts:

sasjs/server     mounted on the same origin behind EMULATE_VIYA, so a Viya app runs unchanged
sasjs/cli mock   the same table inside the service worker, so the whole emulator runs in a browser
sasjs/mocks      retired; any fixture in it that still describes a current answer moves into the corpus

Rules the implementation follows:

  • The table is the contract. A status the table does not state is a build failure at import, not a runtime surprise.
  • Every operation is annotated: implemented, or declined with a reason that travels into the 501 body.
  • Corpus cases pin shapes and status sets, never minted identifiers or timestamps, because hosts mint differently.
  • Handlers answer Viya's surface by calling DriveService, IdentityService and RuntimeService. No rule is implemented twice.

5. Contract capture and version pinning

Viya publishes no machine-readable document for these routes, so the contract is captured from a real estate, run by the operator against an account with the permissions the emulation needs. No estate name, host or credential belongs in this repository or in the package.

Capture harness (Phase 0 deliverable):

  • A recorder in front of the adapter, as a proxy or as an interceptor in a scratch build, capturing path, verb, request headers and shape, status, response headers, body and timing per call.
  • One scripted pass per family: token, current user, folder item, folder members, file create/read/replace/delete, context list, session create, job submit/poll/log/output, job execution submit/state, app stream publish/serve.
  • A generator that turns captures into src/viya/table.ts and src/corpus/viya/*.ts with stable ordering and a reviewable diff.

Version pinning is what turns drift into a report rather than a surprise:

  • src/viya/version.ts declares one VIYA_TARGET_VERSION. The emulator emulates that version, and only that version, and names it in every response.
  • Every operation and every corpus case records the version it was captured against, so a case that describes an older shape is visible as such.
  • A scheduled drift job, not part of PR CI, re-captures against an estate of the pinned version and reports differences as an issue with the operations, statuses and fields that moved. A drift report never reddens an unrelated pull request.
  • The first pinned version is whatever the first estate made available for capture runs. It is recorded with its capture date, so a reader can see how old the contract is. Pinning ahead of what an estate can capture would produce a table nobody can verify.
  • Raising VIYA_TARGET_VERSION is a deliberate change: table, corpus, drift baseline and header move in one commit, with a fresh capture to justify it.

Where an operation cannot be captured, for permission or sequencing reasons, it is marked recorded: false with the reason, and its confidence is stated as lower. A table without captures is a guess wearing a schema, and the marker keeps that visible.

6. Making emulation unmistakable

Emulated answers must be identifiable by a machine and by a person, because a demo that looks like production is a support risk.

  • Every response carries x-sasjs-emulated: viya@<VIYA_TARGET_VERSION>, including refusals and 404s. A 404 from the emulator is not evidence about Viya.
  • Where the browser reads the response across an origin, the header is exposed through Access-Control-Expose-Headers.
  • The server's status output and the settings documentation state plainly, at startup, that Viya emulation is on, which version it emulates, and that it is not a Viya deployment.
  • The CLI's mock build stamps the same header and the same version into the site it generates.
  • The CLI and the server both print a one-line warning on start, once, so a captured console log says which mode produced it.

7. Authentication design

The adapter fixes the vocabulary. These paths are string literals in @sasjs/adapter:

getAccessTokenForViya.ts:28        /SASLogon/oauth/token
refreshTokensForViya.ts:26         /SASLogon/oauth/token
SASViyaApiClient.ts:761            /SASLogon/oauth/authorize
AuthManager.ts:18,26,29-32         /SASLogon, /SASLogon/login, /SASLogon/logout(.do)
AuthManager.ts:175,260,370         GET /SASLogon/login?service=..., login.do to login
verifySasViyaLogin.ts:31           location.href.includes('SASLogon')
verifySas9Login.ts:16              location.href.includes('SASLogon')
RequestClient.ts:855               url.includes('/SASLogon/login')

Renaming the emulator's paths is therefore not available: it would require patching the adapter (the app stops being stock) or a configurable auth base path (every app must set it). Both defeat the purpose. The emulator answers /SASLogon/*.

The server's own auth paths stay where they are:

POST /SASLogon/login    JSON body, CSRF cookie, 429 lockout, JSON answer   (owned)
GET  /SASLogon/logout   clears the session, re-issues the CSRF cookie       (owned)
/SASLogon/authorize     server SPA and OIDC flow                            (owned)
/SASLogon/openid, /openid/callback, /openid/logout                          (owned, registered externally)

docs/pages/auth.md:60-65 instructs operators to register https://<host>/SASLogon/openid/callback with their identity provider, and loginRedirectUri at :130 is the same path, including in the Cloudron deployment notes. Renaming those paths moves a migration onto every customer. They stay.

The overlap is one method on one path, with a clean split:

/SASLogon/oauth/token        uncontested   the server serves no such path
/SASLogon/oauth/authorize    uncontested
GET  /SASLogon/login         uncontested   the server owns POST only
POST /SASLogon/login.do      uncontested   the server never serves it
POST /SASLogon/login         SHARED       split by request content type
GET  /SASLogon/logout        SHARED       split by request shape

For the shared POST the discriminator is what the adapter already implements: AuthManager.sendLoginRequest posts application/json for ServerType.Sasjs and application/x-www-form-urlencoded for Viya and SAS9 to the same path. The server's route keeps its JSON contract; the emulated branch answers form-encoded logins. Each side pins its case, and the route description states the rule.

If a future major wants a namespaced home for SASjs's own auth, the destination is /SASjsApi/auth/login, beside /SASjsApi/auth/token, /refresh and /logout, with /SASLogon/login kept as a documented alias for a release. /SASjsLogon and /logon are rejected: the first duplicates an existing namespace, the second invents a third name for a thing that already has two.

8. Data: generated, seeded, reproducible

Data is generated, never captured. Captured values belong to the estate they came from and would put estate data in a repository.

  • A seed comes from the target configuration. The same seed produces the same rows, the same pages and the same screenshots, so a ticket's evidence stays true after a rebuild.
  • Structure is always emulated: folders, files, sessions, jobs, identities, tokens, launch pages, app stream entries, backed by the same ports the server uses.
  • Results are generated from a program's declared @input and @output tables: column names come from the program, row counts and values from the seed and the table's declared types.
  • Execution is optional. Where the server runs with RUN_TIMES=js or RUN_TIMES=sas, the emulated session runs the program for real against the generated tables and returns the real webout. That widens the spectrum without changing the contract, and the status output states which mode is in force.

9. Failure fixtures: the ticket payoff

Every failure shape that matters becomes a corpus case the adapter must survive, so the case that reproduces a defect also guards its fix:

adapter#895   target unreachable      transport timeout at /SASLogon/oauth/token
adapter#837   invalid JSON            body is HTML, or truncated JSON, with a log present
adapter#803   debug on                log is an object, an array, and a string
cli#1350      failed job, no log      job state 'failed' with no log href, parent session has one

Each case names its status set and body shape, and each runs through the real transport of every host that mounts the table.

10. Phases

Phase 0 - capture and pin.
Deliverables: recorder, generator, src/viya/table.ts, src/corpus/viya/*.ts, src/viya/version.ts with the pinned target, and a measurement note giving the operation count per family and the list marked recorded: false.
Exit criteria: every operation carries a captured case or a stated reason; the scope guard compares the table against the adapter's path literals and agrees; the pinned version appears in the table and in the header.

Phase 1 - the package table.
Deliverables: handlers for the families in section 3, the parity spec (table against corpus, both directions, every operation annotated), the 501 path with reasons, and the emulated header on every answer.
Exit criteria: parity spec green and biting when a status is added on one side only; the full corpus replays with no host attached.

Phase 2 - mount in sasjs/server behind EMULATE_VIYA=true.
Deliverables: same-origin mounting, the /SASLogon split from section 7, conformance replay over the server's real HTTP surface (the pattern in api/src/routes/api/spec/driveCorpus.spec.ts), a documentation page, a settings entry, and the startup notice.
Exit criteria: a seed app reconfigured to serverType=SASVIYA authenticates, runs a service, renders webout and serves its app against the server with no Viya present; the corpus replays green over HTTP; the server's own login tests still pass.

Phase 3 - the same table in the CLI, and in a browser.
Deliverables: the Viya table inside the service worker, replacing the hand-written Viya-adjacent pieces, retiring sasjs/mocks routes, a mock-web build of a Viya-targeted app, and the emulated header stamped into the generated site.
Exit criteria: the app runs in a browser with no backend; the same corpus cases pass in the worker; a mock-compiled Viya app is indistinguishable from the server-hosted one apart from the header and the data.

Phase 4 - tooling.
Deliverables: CLI support so an emulated Viya is one command, the seed in the target configuration, and the drift job wired on a schedule.
Exit criteria: a documented seed reproduces a dataset and a rendering across hosts; a drift report names operations rather than dumping a diff.

11. Decisions

Decided:

  1. Implementation lives in @sasjs/api as a second route table. One dispatcher, one corpus runner, three hosts.
  2. The server is the first host; the CLI's browser host follows with the same table, and full mock compatibility for the CLI is a completion criterion, not a later idea.
  3. Scope is the adapter's call surface, enforced by a guard against the adapter's path literals. Routes Viya has but the adapter never calls are out.
  4. Data is generated from a seed, never captured from an estate.
  5. Every response carries x-sasjs-emulated: viya@<VIYA_TARGET_VERSION>, and the runtime states the same in its status output and startup notice.
  6. The contract is pinned to one VIYA_TARGET_VERSION; captures record the version they came from; drift is reported by a scheduled job, not by PR CI.
  7. Same-origin activation behind EMULATE_VIYA, because the adapter's serverUrl must be the app's own origin for cookies and same-origin behaviour to match.
  8. One interface, two implementations: RUN_TIMES=sasviya proxies to a real Viya, EMULATE_VIYA answers an emulated one, and a target chooses between them.
  9. No path renames. /SASLogon/* belongs to the emulator because the adapter requires it; the server's own paths stay where customers have registered them; the shared POST splits by content type.
  10. MOCK_SERVERTYPE stays until the new mode covers its use; sasjs/mocks retires once nothing in it describes a current answer.
  11. The first pinned version is the version the first estate made available for capture runs. Pinning is a contract, so it is recorded with its capture date and raised deliberately.

Open, to settle during Phase 0:

  1. The exact header spelling and whether a second header separates version from mode. Default: x-sasjs-emulated: viya@<version>, one header, one fact.
  2. Whether the seed lives in the target configuration or in a project file. Default: the target, beside serverUrl and appLoc.

12. Risks

  • Fidelity is the cost driver. Viya's shapes are undocumented in places and move between versions. Capture plus the version pin is what keeps the table honest.
  • Some operations start without captures, so confidence is not uniform. The recorded flag states that per operation rather than leaving it implied.
  • The emulator can drift into a second implementation of the server's own logic. Handlers call the services; translation sits in one module; the parity spec is the ratchet.
  • The auth split is one path carrying two protocols, so it needs a case on each side and a sentence in the route description, or the next reader will not know which answer they are looking at.
  • Emulated data can be mistaken for real output. The header, the startup notice and the status line are the defence, and they are not optional.
  • Scope creep. The guard against the adapter's path literals is the mechanism that keeps the surface at what the client uses.

13. First actions

  1. Open the tracking issue with sections 1 to 3 as its statement of purpose and scope.
  2. Build the recorder and run one scripted pass per family against an operator-supplied estate (Phase 0).
  3. Record VIYA_TARGET_VERSION as the version of the first estate made available for capture, with its capture date; generate the table and publish the per-family operation count.
  4. Land sasjs/adapter#895 in parallel: it is one file, it stops a credential from reaching a log, and its failure shape becomes the emulator's first case.

Prior art in this estate

  • sasjs/adapter#968 - the transport-failure half of adapter#895, which supplies the first failure case for section 9.
  • sasjs/cli#1550 - the same "one description, several hosts" argument for the SASjs API, and the pattern this plan copies.
  • sasjs/cli#1585 - the mock app stream mounted through @sasjs/api's dispatcher, which is the shape Phase 3 follows.
  • sasjs/server#506 - Viya-shaped session state, the closest existing piece of the answer.
  • docs/viya-runtime.md - the RUN_TIMES=sasviya proxy, the other implementation of the same interface.

Status

Updated 2026-11-12. Measured against a reachable Viya 4 estate on that date; no estate name, host or credential is recorded here or in the repository.

The recorder landed in sasjs/api #71, and section 8's blocker has moved: an estate answered from this environment on 2026-11-12, so the capture pass ran. #79 records the first real captures and the defect the run found - the write step passed the access token into createFolder's parentFolderPath, so the request left unauthenticated, the estate answered 401, and the step still logged ok. #80 fixes a CI step on main that has been red since #78 by comparing the three builds with each other rather than with a route-count literal.

Captured so far, with every request authorized and every capture passing its own leak verifier:

identities   GET  /identities/users/@currentUser
compute      GET  /compute/contexts            (list, and filter= by name)
compute      POST /compute/contexts/{id}/sessions, GET .../state, DELETE the session
folders      GET  /folders/folders/@item?path=..., GET .../{id}/members
folders      POST /folders/folders, PATCH /folders/folders/{id} (a move to the recycle bin)
files        GET  /types/types, POST /files/files, PATCH /files/files/{id}
files        GET  /files/files/{id}/content, GET /files/files/{id}, PUT .../{id}/content

Three contract facts the captures established that the family list in section 5 did not anticipate:

  • File creation is three requests, not one: /types/types for the extension's type definition, then POST, then PATCH with the properties.
  • deleteFolder is a PATCH that moves the folder into the recycle bin, and the client reads @myRecycleBin on the way. An emulator answering DELETE would be answering a call nobody makes.
  • Every file read and replace lists the parent folder's members first, so a member list sits on the path of the file family.

Not captured yet, and each is a separate pass: job execution (/jobExecution/jobs, submit, poll, log, output), the app stream (publish and serve), and uploadFile's multipart path. They need a deployed service on the estate, which the first pass did not assume.

Correction to section 3's scope, on the evidence rather than on Viya's route list: deleting a file is not part of @sasjs/adapter's surface - the client creates, reads and replaces, and never removes - so /files/files/{id} with DELETE is out of scope by decision 3, and the recorder's own cleanup of the file it created is marked cleanup and kept out of the table.

The execute family is captured too: executeScript with the log, which is also where the pinned version comes from. %put &=sysvlong;, run through the adapter in Compute Reusable, returns:

V.04.00M0P100526

captured 2026-11-12. That is the value VIYA_TARGET_VERSION pins to, and the value decision 12's header names.

Decision 12 is settled: one header, one fact - x-sasjs-emulated: [email protected]. Decision 13 (where the seed lives) is the remaining open one, and it is visible in every table the generator emits.

One behaviour of the recorder's own client that a host will meet: @sasjs/adapter 6.0.2 treats a brand-new one-hour token as expiring - its refresh margin equals the estate's TTL - so executeScript refreshes before submitting, and that refresh rotates the token pair. A host answering the emulated token endpoint has to answer the refresh grant, not only the password grant.

The scope guard is the other half of Phase 0's exit criterion, and it is open as sasjs/api#81. It measures the client rather than asserting the surface: tools/viya-capture/surface.mjs reads the installed adapter and lists every path literal it builds (56 from 6.0.2), spec/viya/adapter-paths.json is that list committed, and src/viya/surface.ts states in or declined with a reason for each. npm run check:scope holds the inventory against the adapter, and spec/viyaScope.spec.ts holds the declaration against the inventory, in both directions.

Two shapes the measurement produced that a hand-written list would not have: /identities/users/@currentUser is built as a template that starts with the server URL, so an extractor demanding a leading slash misses the call every session begins with; and /compute/sessions/{}/variables/{} and /jobDefinitions/definitions are calls no six-family summary mentions.

Phase 0 is complete as data: sasjs/api#82 generates src/viya/table.ts (21 operations from 8 capture runs), src/corpus/viya/operations.ts (the same capture as replayable cases) and src/viya/unrecorded.ts (the 14 in-scope operations with no capture, each with a reason that names what is missing), and pins VIYA_TARGET_VERSION with its capture date and the header the answers carry. A capture never enters the repository: paths keep their shape, queries keep their names, bodies become type sketches, and a spec fails if an identifier, host or filesystem path reaches the generated files.

The next pass is the one unrecorded family with real work behind it: a deployed job definition, so /jobExecution/jobs and /jobDefinitions/definitions can be captured. After that, Phase 1 - handlers, the overlay and dispatchViya over the same services.

What the token endpoint has to answer

Section 7 states the paths; the grants are decided by the client software, so they are stated here. Measured in @sasjs/cli and @sasjs/adapter rather than assumed:

  • password - sasjs auth login (Viya only) posts grant_type=password to /SASLogon/oauth/token from cli/src/utils/auth.ts, against the secret-less sas.cli public client. The CLI's own code, not the adapter's.
  • authorization_code - sasjs auth (alias sasjs add cred) sends the user to /SASLogon/oauth/authorize?client_id=...&response_type=code with a registered client and secret, then exchanges the pasted code. That exchange is the adapter's getAccessTokenForViya.
  • refresh_token - the adapter's getTokens refreshes with the configured client, falling back to sas.cli with an empty secret, so a token minted by auth login can be refreshed. refreshTokensForViya posts grant_type=refresh_token.
  • client_credentials - nothing in either package asks for it. It is out of scope by decision 3, which is worth stating because a family list written from Viya's surface would have included it.

The recorder now captures the refresh grant (against sas.cli) and the table carries POST /SASLogon/oauth/token; the password and authorization-code grants are the two the emulated endpoint has to implement without a capture, because obtaining either needs a browser (a code) or a password the recorder is not given.

A SASJS target needs none of this: the CLI reproduces the server's own sign-in (GET / for the XSRF cookie, POST /SASLogon/login, POST /SASLogon/authorize, POST /SASjsApi/auth/token), which is section 7's shared-POST split seen from the client side.

The seed is the target's serverUrl

Decision 13 asked whether the seed lives in the target configuration or in a project file. It needs neither: the seed is derived from the target's serverUrl, because the URL already identifies which estate a target talks to, and two targets pointing at one emulator are pointing at one estate.

seedOf(target) = target.seed ?? normalise(target.serverUrl) ?? EMULATED_DEFAULT

normalise: lowercase host, drop a default port (80/443), drop a trailing slash, keep a non-default port

seed therefore becomes an optional override rather than a required field, for the two cases where an address is the wrong identity: the same emulator reached at another host (localhost versus a container name), and a port that moves between runs (an ephemeral CI port would otherwise change the data and invalidate every screenshot the seed exists to keep true).

The cost is deliberate: one emulator serves one dataset, so two suites that want different fixtures need two emulators. An address that gave different callers different rows would be the surprising behaviour, and appLoc is not mixed in either - moving an app to another folder must not change its data.

Snapshot

A one-week snapshot of this text is at https://paste.4gl.io/?c3eab5bf1d7e703c#DMDFvw9yv3WD1unNRCjZdyL1f2wcQCdDkJQ1fXqzqf5n - this issue is the authority.

Lenguaje dominante
TypeScript
Estrellas
22
Forks
3
Merge medio
1 h 47 min
PR fusionados (30 d)
200

Preparar el entorno

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Issues similares

Más issues de TypeScript

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.